diff --git a/.ci/android/build.sh b/.ci/android/build.sh new file mode 100755 index 000000000..3c1e2f240 --- /dev/null +++ b/.ci/android/build.sh @@ -0,0 +1,12 @@ +#!/bin/bash -ex + +export NDK_CCACHE=$(which ccache) + +ccache -s + +cd src/android +chmod +x ./gradlew +./gradlew bundleRelease +./gradlew assembleRelease + +ccache -s diff --git a/.ci/android/upload.sh b/.ci/android/upload.sh new file mode 100755 index 000000000..64258dfd5 --- /dev/null +++ b/.ci/android/upload.sh @@ -0,0 +1,10 @@ +#!/bin/bash -ex + +. ./.ci/common/pre-upload.sh + +REV_NAME="citra-${GITDATE}-${GITREV}" + +cp src/android/app/build/outputs/apk/release/app-release.apk \ + "artifacts/${REV_NAME}.apk" +cp src/android/app/build/outputs/bundle/release/app-release.aab \ + "artifacts/${REV_NAME}.aab" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd0f4f0ca..5381af829 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -107,6 +107,38 @@ jobs: shell: bash env: ENABLE_COMPATIBILITY_REPORTING: "ON" + android: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + with: + submodules: recursive + - name: Set up cache + uses: actions/cache@v2 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + ~/.ccache + key: ${{ runner.os }}-android-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-android- + - name: Query tag name + uses: little-core-labs/get-git-tag@v3.0.2 + id: tagName + - name: Deps + run: | + sudo apt-get update + sudo apt-get install ccache -y + - name: Build + run: ./.ci/android/build.sh + - name: Copy artifacts + run: ./.ci/android/upload.sh + - name: Upload + uses: actions/upload-artifact@v2 + with: + name: android + path: artifacts/ transifex: runs-on: ubuntu-latest container: citraemu/build-environments:linux-transifex diff --git a/CMakeLists.txt b/CMakeLists.txt index 5f0bee895..4a3cedcbd 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -49,6 +49,14 @@ if(NOT EXISTS ${PROJECT_SOURCE_DIR}/.git/hooks/pre-commit) DESTINATION ${PROJECT_SOURCE_DIR}/.git/hooks) endif() +# Use ccache for android if available +# ======================================================================= +if (NOT $ENV{NDK_CCACHE} EQUAL "") + set(CCACHE_EXE $ENV{NDK_CCACHE}) + set(CMAKE_C_COMPILER_LAUNCHER ${CCACHE_EXE}) + set(CMAKE_CXX_COMPILER_LAUNCHER ${CCACHE_EXE}) +endif() + # Sanity check : Check that all submodules are present # ======================================================================= diff --git a/bitrise.yml b/bitrise.yml index e95157a17..271858f9a 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -1,5 +1,5 @@ --- -format_version: '6' +format_version: '11' default_step_lib_source: https://github.com/bitrise-io/bitrise-steplib.git project_type: android trigger_map: @@ -7,118 +7,83 @@ trigger_map: workflow: primary - pull_request_source_branch: "*" workflow: primary +- tag: "*" + workflow: deploy workflows: deploy: - description: | - ## How to get a signed APK - - This workflow contains the **Sign APK** step. To sign your APK all you have to do is to: - - 1. Click on **Code Signing** tab - 1. Find the **ANDROID KEYSTORE FILE** section - 1. Click or drop your file on the upload file field - 1. Fill the displayed 3 input fields: - 1. **Keystore password** - 1. **Keystore alias** - 1. **Private key password** - 1. Click on **[Save metadata]** button - - That's it! From now on, **Sign APK** step will receive your uploaded files. - - ## To run this workflow - - If you want to run this workflow manually: - - 1. Open the app's build list page - 2. Click on **[Start/Schedule a Build]** button - 3. Select **deploy** in **Workflow** dropdown input - 4. Click **[Start Build]** button - - Or if you need this workflow to be started by a GIT event: - - 1. Click on **Triggers** tab - 2. Setup your desired event (push/tag/pull) and select **deploy** workflow - 3. Click on **[Done]** and then **[Save]** buttons - - The next change in your repository that matches any of your trigger map event will start **deploy** workflow. steps: - - cache-pull@2.4.0: {} - - script@1.1.6: + - activate-ssh-key@4: {} + - git-clone@6: {} + - cache-pull@2: {} + - script@1: title: Install newer cmake inputs: - - content: |- - #!/bin/bash - set -ex - sudo apt remove cmake -y - sudo apt purge --auto-remove cmake -y - sudo apt install ninja-build -y - version=3.19 - build=2 - mkdir ~/temp - cd ~/temp - wget https://cmake.org/files/v$version/cmake-$version.$build-Linux-x86_64.sh - sudo mkdir /opt/cmake - sudo sh cmake-$version.$build-Linux-x86_64.sh --prefix=/opt/cmake --skip-license --exclude-subdir - envman add --key PATH --value "/opt/cmake/bin:$PATH" - - install-missing-android-tools@2.3.8: + - content: |- + #!/bin/bash + set -ex + sdkmanager --install "cmake;3.18.1" + - install-missing-android-tools@2.3: inputs: - gradlew_path: "$PROJECT_LOCATION/gradlew" - - change-android-versioncode-and-versionname@1.1.1: - inputs: - - build_gradle_path: "$PROJECT_LOCATION/$MODULE/build.gradle" - - android-lint@0.9.8: - inputs: - - project_location: "$PROJECT_LOCATION" - - module: "$MODULE" - - variant: "$TEST_VARIANT" - - android-unit-test@0.9.3: - inputs: - - project_location: "$PROJECT_LOCATION" - - module: "$MODULE" - - variant: "$TEST_VARIANT" - - android-build@0.10.3: + - android-lint@0: inputs: - project_location: "$PROJECT_LOCATION" - module: "$MODULE" - variant: "$BUILD_VARIANT" - - sign-apk@1.2.3: + - android-build@0: + inputs: + - variant: "$BUILD_VARIANT" + - project_location: "$PROJECT_LOCATION" + - build_type: aab + - sign-apk@1: run_if: '{{getenv "BITRISEIO_ANDROID_KEYSTORE_URL" | ne ""}}' - - deploy-to-bitrise-io@1.11.1: {} - - cache-push@2.4.1: {} + - bitrise-step-export-universal-apk@0: + run_if: '{{getenv "BITRISEIO_ANDROID_KEYSTORE_URL" | ne ""}}' + - generate-changelog@0: {} + - github-release@0: + run_if: '{{getenv "GITHUB_API_TOKEN" | ne ""}}' + inputs: + - api_token: "$GITHUB_API_TOKEN" + - name: "$BITRISE_GIT_TAG" + - body: "$BITRISE_CHANGELOG" + - files_to_upload: |- + $BITRISE_AAB_PATH|citra-$BITRISE_GIT_TAG.aab + $BITRISE_APK_PATH|citra-$BITRISE_GIT_TAG.apk + - username: "$BITRISEIO_GIT_REPOSITORY_OWNER" + - deploy-to-bitrise-io@1.3: + run_if: '{{getenv "BITRISEIO_ANDROID_KEYSTORE_URL" | ne ""}}' + - cache-push@2: {} + - deploy-to-bitrise-io@2: {} primary: steps: - - cache-pull@2.4.0: {} - - script@1.1.6: - title: Install newer cmake + - activate-ssh-key@4: {} + - git-clone@6: {} + - cache-pull@2: {} + - script@1: + title: Deps inputs: - - content: |- - #!/bin/bash - set -ex - sudo apt remove cmake -y - sudo apt purge --auto-remove cmake -y - sudo apt install ninja-build -y - version=3.19 - build=2 - mkdir ~/temp - cd ~/temp - wget https://cmake.org/files/v$version/cmake-$version.$build-Linux-x86_64.sh - sudo mkdir /opt/cmake - sudo sh cmake-$version.$build-Linux-x86_64.sh --prefix=/opt/cmake --skip-license --exclude-subdir - envman add --key PATH --value "/opt/cmake/bin:$PATH" - - install-missing-android-tools@2.3.8: + - content: |- + #!/bin/bash + set -ex + sdkmanager --install "cmake;3.18.1" + - install-missing-android-tools@3: inputs: - - gradlew_path: "$PROJECT_LOCATION/gradlew" - - android-lint@0.9.8: + - gradlew_path: "$PROJECT_LOCATION/gradlew" + - android-lint@0: inputs: - project_location: "$PROJECT_LOCATION" - module: "$MODULE" - - variant: "$TEST_VARIANT" - - android-build@0.10.3: + - variant: "$BUILD_VARIANT" + - android-build@1: inputs: - - variant: Debug + - variant: "$BUILD_VARIANT" - project_location: "$PROJECT_LOCATION" - - deploy-to-bitrise-io@1.11.1: {} - - cache-push@2.4.1: {} + - build_type: apk + - cache-push@2: {} + - deploy-to-bitrise-io@2: {} +meta: + bitrise.io: + stack: linux-docker-android-20.04 app: envs: - opts: @@ -132,4 +97,3 @@ app: BUILD_VARIANT: Release - opts: is_expand: false - TEST_VARIANT: Debug diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 26d2fb869..7ec5dc26a 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -117,8 +117,10 @@ endif() if (ENABLE_QT) add_subdirectory(citra_qt) endif() + if (ANDROID) - add_subdirectory(android/app/src/main/cpp) + add_subdirectory(android/app/src/main/jni) + target_include_directories(citra-android PRIVATE android/app/src/main) else() add_subdirectory(dedicated_room) endif() diff --git a/src/android/.gitignore b/src/android/.gitignore index 4423a0b45..40b6c5cd0 100644 --- a/src/android/.gitignore +++ b/src/android/.gitignore @@ -1,12 +1,46 @@ +# 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 -.gradle -/local.properties -/.idea/libraries -/.idea/modules.xml -/.idea/workspace.xml -.DS_Store -/build -/captures +.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 diff --git a/src/android/app/build.gradle b/src/android/app/build.gradle index e57271517..c7bb90131 100644 --- a/src/android/app/build.gradle +++ b/src/android/app/build.gradle @@ -1,8 +1,17 @@ apply plugin: 'com.android.application' +/** + * 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. + */ +def autoVersion = (int) (((new Date().getTime() / 1000) - 1451606400) / 10) +def buildType +def abiFilter = "arm64-v8a" //, "x86" + android { - compileSdkVersion 26 - buildToolsVersion '28.0.3' + compileSdkVersion 29 + ndkVersion "23.1.7779620" compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 @@ -13,34 +22,54 @@ android { // 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 { - applicationId "org.citra_emu" - minSdkVersion 21 - targetSdkVersion 26 - - versionCode(getBuildVersionCode()) - - versionName "${getVersion()}" + // TODO If this is ever modified, change application_id in strings.xml + applicationId "org.citra.citra_emu" + minSdkVersion 26 + targetSdkVersion 29 + versionCode autoVersion + versionName getVersion() + ndk.abiFilters abiFilter } signingConfigs { - release { - if (project.hasProperty('keystore')) { - storeFile file(project.property('keystore')) - storePassword project.property('storepass') - keyAlias project.property('keyalias') - keyPassword project.property('keypass') - } - } + //release { + // storeFile file('') + // storePassword System.getenv('ANDROID_KEYPASS') + // keyAlias = 'key0' + // keyPassword System.getenv('ANDROID_KEYPASS') + //} + } + + applicationVariants.all { variant -> + buildType = variant.buildType.name // sets the current build type } // Define build types, which are orthogonal to product flavors. buildTypes { + // Signed by release key, allowing for upload to Play Store. release { - signingConfig signingConfigs.release + signingConfig signingConfigs.debug + } + + // builds a release build that doesn't need signing + // Attaches 'debug' suffix to version and package name, allowing installation alongside the release build. + relWithDebInfo { + initWith release + applicationIdSuffix ".debug" + versionNameSuffix '-debug' + signingConfig signingConfigs.debug + minifyEnabled false + testCoverageEnabled false + debuggable true + jniDebuggable true } // Signed by debug key disallowing distribution on Play Store. @@ -49,13 +78,14 @@ android { // TODO If this is ever modified, change application_id in debug/strings.xml applicationIdSuffix ".debug" versionNameSuffix '-debug' + debuggable true jniDebuggable true } } externalNativeBuild { cmake { - version getCmakeVersion() + version "3.18.1" path "../../../CMakeLists.txt" } } @@ -65,76 +95,46 @@ android { cmake { arguments "-DENABLE_QT=0", // Don't use QT "-DENABLE_SDL2=0", // Don't use SDL - "-DANDROID_ARM_NEON=true", // cryptopp requires Neon to work - "-DENABLE_CUBEB=0", - "-DANDROID_STL=c++_shared" + "-DENABLE_WEB_SERVICE=0", // Don't use telemetry + "-DANDROID_ARM_NEON=true" // cryptopp requires Neon to work - abiFilters "arm64-v8a" - - targets "citra-android" + abiFilters abiFilter } } } } -ext { - androidSupportVersion = '26.1.0' -} - dependencies { - implementation "com.android.support:support-v13:$androidSupportVersion" - implementation "com.android.support:cardview-v7:$androidSupportVersion" - implementation "com.android.support:recyclerview-v7:$androidSupportVersion" - implementation "com.android.support:design:$androidSupportVersion" + implementation 'androidx.appcompat:appcompat:1.1.0' + implementation 'androidx.exifinterface:exifinterface:1.2.0' + implementation 'androidx.cardview:cardview:1.0.0' + implementation 'androidx.recyclerview:recyclerview:1.1.0' + implementation 'com.google.android.material:material:1.1.0' - // Android TV UI libraries. - implementation "com.android.support:leanback-v17:$androidSupportVersion" + // For loading huge screenshots from the disk. + implementation 'com.squareup.picasso:picasso:2.71828' - implementation 'com.android.support.constraint:constraint-layout:1.1.0' + // Allows FRP-style asynchronous operations in Android. + implementation 'io.reactivex:rxandroid:1.2.1' + implementation 'com.nononsenseapps:filepicker:4.2.1' + implementation 'org.ini4j:ini4j:0.5.4' + implementation 'androidx.constraintlayout:constraintlayout:1.1.3' + implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.0.0' + implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.0.0' - testImplementation "com.android.support.test:runner:1.0.2" - androidTestImplementation "com.android.support.test:runner:1.0.1" + implementation 'com.android.billingclient:billing:2.0.3' } def getVersion() { - def versionNumber = '0.0' + def versionName = '0.0' try { - versionNumber = 'git describe --always --long'.execute([], project.rootDir).text + versionName = 'git describe --always --long'.execute([], project.rootDir).text .trim() .replaceAll(/(-0)?-[^-]+$/, "") - } catch (Exception e) { + } catch (Exception) { logger.error('Cannot find git, defaulting to dummy version number') } - return versionNumber -} - - -def getBuildVersionCode() { - try { - def versionNumber = 'git rev-list --first-parent --count HEAD'.execute([], project.rootDir).text - .trim() - return Integer.valueOf(versionNumber) - } catch (Exception e) { - logger.error('Cannot find git, defaulting to dummy version number') - } - - return 0 -} - -def getCmakeVersion() { - try { - // Tokenized form of the output will be - ["cmake", "version", "M.m.p-rcx"], the version number - // will be at index 2 - def version_string = 'cmake -version'.execute([], project.rootDir).text - .trim().tokenize()[2] - - return version_string - } - catch(Exception e) { - logger.error('Cannot find Cmake, using default Cmake') - } - - return null + return versionName } diff --git a/src/android/app/src/androidTest/java/org/citra_emu/citra/ExampleInstrumentedTest.java b/src/android/app/src/androidTest/java/org/citra/citra_emu/ExampleInstrumentedTest.java similarity index 84% rename from src/android/app/src/androidTest/java/org/citra_emu/citra/ExampleInstrumentedTest.java rename to src/android/app/src/androidTest/java/org/citra/citra_emu/ExampleInstrumentedTest.java index 7055de885..671fb4b30 100644 --- a/src/android/app/src/androidTest/java/org/citra_emu/citra/ExampleInstrumentedTest.java +++ b/src/android/app/src/androidTest/java/org/citra/citra_emu/ExampleInstrumentedTest.java @@ -1,4 +1,4 @@ -package org.citra_emu.citra; +package org.citra.citra_emu; import android.content.Context; import android.support.test.InstrumentationRegistry; @@ -21,6 +21,6 @@ public class ExampleInstrumentedTest { // Context of the app under test. Context appContext = InstrumentationRegistry.getTargetContext(); - assertEquals("org.citra_emu.citra_android", appContext.getPackageName()); + assertEquals("org.citra.citra_emu", appContext.getPackageName()); } } diff --git a/src/android/app/src/main/AndroidManifest.xml b/src/android/app/src/main/AndroidManifest.xml index c1e38446a..b51382d21 100644 --- a/src/android/app/src/main/AndroidManifest.xml +++ b/src/android/app/src/main/AndroidManifest.xml @@ -1,39 +1,91 @@ + package="org.citra.citra_emu"> - - + + + + + + + + + android:banner="@mipmap/ic_launcher" + android:requestLegacyExternalStorage="true"> + android:name="org.citra.citra_emu.ui.main.MainActivity" + android:theme="@style/CitraBase" + android:resizeableActivity="false"> - + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/android/app/src/main/cpp/CMakeLists.txt b/src/android/app/src/main/cpp/CMakeLists.txt deleted file mode 100644 index f3a7e0131..000000000 --- a/src/android/app/src/main/cpp/CMakeLists.txt +++ /dev/null @@ -1,16 +0,0 @@ -cmake_minimum_required(VERSION 3.8) - -add_library(citra-android SHARED - logging/log.cpp - logging/logcat_backend.cpp - logging/logcat_backend.h - native_interface.cpp - native_interface.h - ui/main/main_activity.cpp - ) - -# find Android's log library -find_library(log-lib log) - -target_link_libraries(citra-android ${log-lib} core common inih) -target_include_directories(citra-android PRIVATE "../../../../../" "./") diff --git a/src/android/app/src/main/cpp/logging/log.cpp b/src/android/app/src/main/cpp/logging/log.cpp deleted file mode 100644 index 044f4eb4c..000000000 --- a/src/android/app/src/main/cpp/logging/log.cpp +++ /dev/null @@ -1,15 +0,0 @@ -#include "common/logging/log.h" -#include "native_interface.h" - -namespace Log { -extern "C" { -JNICALL void Java_org_citra_1emu_citra_LOG_logEntry(JNIEnv* env, jclass type, jint level, - jstring file_name, jint line_number, - jstring function, jstring msg) { - using CitraJNI::GetJString; - FmtLogMessage(Class::Frontend, static_cast(level), GetJString(env, file_name).data(), - static_cast(line_number), GetJString(env, function).data(), - GetJString(env, msg).data()); -} -} -} // namespace Log diff --git a/src/android/app/src/main/cpp/logging/logcat_backend.cpp b/src/android/app/src/main/cpp/logging/logcat_backend.cpp deleted file mode 100644 index 17b6ae1a0..000000000 --- a/src/android/app/src/main/cpp/logging/logcat_backend.cpp +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright 2019 Citra Emulator Project -// Licensed under GPLv2 or any later version -// Refer to the license.txt file included. - -#include -#include "common/assert.h" -#include "common/logging/text_formatter.h" -#include "logcat_backend.h" - -namespace Log { -void LogcatBackend::Write(const Entry& entry) { - android_LogPriority priority; - switch (entry.log_level) { - case Level::Trace: - priority = ANDROID_LOG_VERBOSE; - break; - case Level::Debug: - priority = ANDROID_LOG_DEBUG; - break; - case Level::Info: - priority = ANDROID_LOG_INFO; - break; - case Level::Warning: - priority = ANDROID_LOG_WARN; - break; - case Level::Error: - priority = ANDROID_LOG_ERROR; - break; - case Level::Critical: - priority = ANDROID_LOG_FATAL; - break; - case Level::Count: - UNREACHABLE(); - } - - __android_log_print(priority, "citra", "%s\n", FormatLogMessage(entry).c_str()); -} -} // namespace Log \ No newline at end of file diff --git a/src/android/app/src/main/cpp/logging/logcat_backend.h b/src/android/app/src/main/cpp/logging/logcat_backend.h deleted file mode 100644 index f3bac4762..000000000 --- a/src/android/app/src/main/cpp/logging/logcat_backend.h +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright 2019 Citra Emulator Project -// Licensed under GPLv2 or any later version -// Refer to the license.txt file included. - -#pragma once - -#include "common/logging/backend.h" - -namespace Log { -class LogcatBackend : public Backend { -public: - static const char* Name() { - return "Logcat"; - } - - const char* GetName() const override { - return Name(); - } - - void Write(const Entry& entry) override; -}; -} // namespace Log diff --git a/src/android/app/src/main/cpp/native_interface.cpp b/src/android/app/src/main/cpp/native_interface.cpp deleted file mode 100644 index fc4d73b77..000000000 --- a/src/android/app/src/main/cpp/native_interface.cpp +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright 2019 Citra Emulator Project -// Licensed under GPLv2 or any later version -// Refer to the license.txt file included. - -#include "native_interface.h" - -namespace CitraJNI { -jint JNI_OnLoad(JavaVM* vm, void* reserved) { - return JNI_VERSION_1_6; -} - -std::string GetJString(JNIEnv* env, jstring jstr) { - std::string result = ""; - if (!jstr) - return result; - - const char* s = env->GetStringUTFChars(jstr, nullptr); - result = s; - env->ReleaseStringUTFChars(jstr, s); - return result; -} -} // namespace CitraJNI diff --git a/src/android/app/src/main/cpp/native_interface.h b/src/android/app/src/main/cpp/native_interface.h deleted file mode 100644 index a7b99cb51..000000000 --- a/src/android/app/src/main/cpp/native_interface.h +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright 2019 Citra Emulator Project -// Licensed under GPLv2 or any later version -// Refer to the license.txt file included. - -#pragma once - -#include -#include - -namespace CitraJNI { -extern "C" { -jint JNI_OnLoad(JavaVM* vm, void* reserved); -} - -std::string GetJString(JNIEnv* env, jstring jstr); -} // namespace CitraJNI diff --git a/src/android/app/src/main/cpp/ui/main/main_activity.cpp b/src/android/app/src/main/cpp/ui/main/main_activity.cpp deleted file mode 100644 index b99ba1890..000000000 --- a/src/android/app/src/main/cpp/ui/main/main_activity.cpp +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright 2019 Citra Emulator Project -// Licensed under GPLv2 or any later version -// Refer to the license.txt file included. - -#include "common/common_paths.h" -#include "common/file_util.h" -#include "common/logging/filter.h" -#include "common/logging/log.h" -#include "core/settings.h" -#include "logging/logcat_backend.h" -#include "native_interface.h" - -namespace MainActivity { -extern "C" { -JNICALL void Java_org_citra_1emu_citra_ui_main_MainActivity_initUserPath(JNIEnv* env, jclass type, - jstring path) { - FileUtil::SetUserPath(CitraJNI::GetJString(env, path) + '/'); -} - -JNICALL void Java_org_citra_1emu_citra_ui_main_MainActivity_initLogging(JNIEnv* env, jclass type) { - Log::Filter log_filter(Log::Level::Debug); - log_filter.ParseFilterString(Settings::values.log_filter); - Log::SetGlobalFilter(log_filter); - - const std::string& log_dir = FileUtil::GetUserPath(FileUtil::UserPath::LogDir); - FileUtil::CreateFullPath(log_dir); - Log::AddBackend(std::make_unique(log_dir + LOG_FILE)); - Log::AddBackend(std::make_unique()); -} -}; -}; // namespace MainActivity diff --git a/src/android/app/src/main/ic_citra-web.png b/src/android/app/src/main/ic_citra-web.png deleted file mode 100644 index 129946a37..000000000 Binary files a/src/android/app/src/main/ic_citra-web.png and /dev/null differ diff --git a/src/android/app/src/main/java/org/citra/citra_emu/CitraApplication.java b/src/android/app/src/main/java/org/citra/citra_emu/CitraApplication.java new file mode 100644 index 000000000..41ac7e27c --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/CitraApplication.java @@ -0,0 +1,56 @@ +// Copyright 2019 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu; + +import android.app.Application; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.content.Context; +import android.os.Build; + +import org.citra.citra_emu.model.GameDatabase; +import org.citra.citra_emu.utils.DirectoryInitialization; +import org.citra.citra_emu.utils.PermissionsHandler; + +public class CitraApplication extends Application { + public static GameDatabase databaseHelper; + private static CitraApplication application; + + private void createNotificationChannel() { + // Create the NotificationChannel, but only on API 26+ because + // the NotificationChannel class is new and not in the support library + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + CharSequence name = getString(R.string.app_notification_channel_name); + String description = getString(R.string.app_notification_channel_description); + NotificationChannel channel = new NotificationChannel(getString(R.string.app_notification_channel_id), name, NotificationManager.IMPORTANCE_LOW); + channel.setDescription(description); + channel.setSound(null, null); + channel.setVibrationPattern(null); + // Register the channel with the system; you can't change the importance + // or other notification behaviors after this + NotificationManager notificationManager = getSystemService(NotificationManager.class); + notificationManager.createNotificationChannel(channel); + } + } + + @Override + public void onCreate() { + super.onCreate(); + application = this; + + if (PermissionsHandler.hasWriteAccess(getApplicationContext())) { + DirectoryInitialization.start(getApplicationContext()); + } + + NativeLibrary.LogDeviceInfo(); + createNotificationChannel(); + + databaseHelper = new GameDatabase(this); + } + + public static Context getAppContext() { + return application.getApplicationContext(); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/NativeLibrary.java b/src/android/app/src/main/java/org/citra/citra_emu/NativeLibrary.java new file mode 100644 index 000000000..90d774f6c --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/NativeLibrary.java @@ -0,0 +1,666 @@ +/* + * Copyright 2013 Dolphin Emulator Project + * Licensed under GPLv2+ + * Refer to the license.txt file included. + */ + +package org.citra.citra_emu; + +import android.app.Activity; +import android.app.Dialog; +import android.content.pm.PackageManager; +import android.content.res.Configuration; +import android.os.Bundle; +import android.text.Html; +import android.text.method.LinkMovementMethod; +import android.view.Surface; +import android.view.ViewGroup; +import android.widget.EditText; +import android.widget.FrameLayout; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.core.content.ContextCompat; +import androidx.fragment.app.DialogFragment; + +import org.citra.citra_emu.activities.EmulationActivity; +import org.citra.citra_emu.applets.SoftwareKeyboard; +import org.citra.citra_emu.utils.EmulationMenuSettings; +import org.citra.citra_emu.utils.Log; +import org.citra.citra_emu.utils.PermissionsHandler; + +import java.lang.ref.WeakReference; +import java.util.Date; +import java.util.Objects; + +import static android.Manifest.permission.CAMERA; +import static android.Manifest.permission.RECORD_AUDIO; + +/** + * Class which contains methods that interact + * with the native side of the Citra code. + */ +public final class NativeLibrary { + /** + * Default touchscreen device + */ + public static final String TouchScreenDevice = "Touchscreen"; + public static WeakReference sEmulationActivity = new WeakReference<>(null); + + private static boolean alertResult = false; + private static String alertPromptResult = ""; + private static int alertPromptButton = 0; + private static final Object alertPromptLock = new Object(); + private static boolean alertPromptInProgress = false; + private static String alertPromptCaption = ""; + private static int alertPromptButtonConfig = 0; + private static EditText alertPromptEditText = null; + + static { + try { + System.loadLibrary("citra-android"); + } catch (UnsatisfiedLinkError ex) { + Log.error("[NativeLibrary] " + ex.toString()); + } + } + + private NativeLibrary() { + // Disallows instantiation. + } + + /** + * 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. + */ + public static native boolean onGamePadEvent(String Device, int Button, int Action); + + /** + * Handles gamepad 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 + */ + public static native boolean onGamePadMoveEvent(String Device, int Axis, float x_axis, float y_axis); + + /** + * Handles gamepad movement events. + * + * @param Device The device ID of the gamepad. + * @param Axis_id The axis ID + * @param axis_val The value of the axis represented by the given ID. + */ + public static native boolean onGamePadAxisEvent(String Device, int Axis_id, float axis_val); + + /** + * Handles touch events. + * + * @param x_axis The value of the x-axis. + * @param y_axis The value of the y-axis + * @param pressed To identify if the touch held down or released. + * @return true if the pointer is within the touchscreen + */ + public static native boolean onTouchEvent(float x_axis, float y_axis, boolean pressed); + + /** + * Handles touch movement. + * + * @param x_axis The value of the instantaneous x-axis. + * @param y_axis The value of the instantaneous y-axis. + */ + public static native void onTouchMoved(float x_axis, float y_axis); + + public static native void ReloadSettings(); + + public static native String GetUserSetting(String gameID, String Section, String Key); + + public static native void SetUserSetting(String gameID, String Section, String Key, String Value); + + public static native void InitGameIni(String gameID); + + /** + * Gets the embedded icon within the given ROM. + * + * @param filename the file path to the ROM. + * @return an integer array containing the color data for the icon. + */ + public static native int[] GetIcon(String filename); + + /** + * 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. + */ + public static native String GetTitle(String filename); + + public static native String GetDescription(String filename); + + public static native String GetGameId(String filename); + + public static native String GetRegions(String filename); + + public static native String GetCompany(String filename); + + public static native String GetGitRevision(); + + /** + * Sets the current working user directory + * If not set, it auto-detects a location + */ + public static native void SetUserDirectory(String directory); + + public static native String[] GetInstalledGamePaths(); + + // Create the config.ini file. + public static native void CreateConfigFile(); + + public static native int DefaultCPUCore(); + + /** + * Begins emulation. + */ + public static native void Run(String path); + + public static native String[] GetTextureFilterNames(); + + /** + * Begins emulation from the specified savestate. + */ + public static native void Run(String path, String savestatePath, boolean deleteSavestate); + + // Surface Handling + public static native void SurfaceChanged(Surface surf); + + public static native void SurfaceDestroyed(); + + public static native void DoFrame(); + + /** + * Unpauses emulation from a paused state. + */ + public static native void UnPauseEmulation(); + + /** + * Pauses emulation. + */ + public static native void PauseEmulation(); + + /** + * Stops emulation. + */ + public static native void StopEmulation(); + + /** + * Returns true if emulation is running (or is paused). + */ + public static native boolean IsRunning(); + + /** + * Returns the performance stats for the current game + **/ + public static native double[] GetPerfStats(); + + /** + * Notifies the core emulation that the orientation has changed. + */ + public static native void NotifyOrientationChange(int layout_option, int rotation); + + /** + * Swaps the top and bottom screens. + */ + public static native void SwapScreens(boolean swap_screens, int rotation); + + public enum CoreError { + ErrorSystemFiles, + ErrorSavestate, + ErrorUnknown, + } + + private static boolean coreErrorAlertResult = false; + private static final Object coreErrorAlertLock = new Object(); + + public static class CoreErrorDialogFragment extends DialogFragment { + static CoreErrorDialogFragment newInstance(String title, String message) { + CoreErrorDialogFragment frag = new CoreErrorDialogFragment(); + Bundle args = new Bundle(); + args.putString("title", title); + args.putString("message", message); + frag.setArguments(args); + return frag; + } + + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + final Activity emulationActivity = Objects.requireNonNull(getActivity()); + + final String title = Objects.requireNonNull(Objects.requireNonNull(getArguments()).getString("title")); + final String message = Objects.requireNonNull(Objects.requireNonNull(getArguments()).getString("message")); + + return new AlertDialog.Builder(emulationActivity) + .setTitle(title) + .setMessage(message) + .setPositiveButton(R.string.continue_button, (dialog, which) -> { + coreErrorAlertResult = true; + synchronized (coreErrorAlertLock) { + coreErrorAlertLock.notify(); + } + }) + .setNegativeButton(R.string.abort_button, (dialog, which) -> { + coreErrorAlertResult = false; + synchronized (coreErrorAlertLock) { + coreErrorAlertLock.notify(); + } + }).setOnDismissListener(dialog -> { + coreErrorAlertResult = true; + synchronized (coreErrorAlertLock) { + coreErrorAlertLock.notify(); + } + }).create(); + } + } + + private static void OnCoreErrorImpl(String title, String message) { + final EmulationActivity emulationActivity = sEmulationActivity.get(); + if (emulationActivity == null) { + Log.error("[NativeLibrary] EmulationActivity not present"); + return; + } + + CoreErrorDialogFragment fragment = CoreErrorDialogFragment.newInstance(title, message); + fragment.show(emulationActivity.getSupportFragmentManager(), "coreError"); + } + + /** + * Handles a core error. + * @return true: continue; false: abort + */ + public static boolean OnCoreError(CoreError error, String details) { + final EmulationActivity emulationActivity = sEmulationActivity.get(); + if (emulationActivity == null) { + Log.error("[NativeLibrary] EmulationActivity not present"); + return false; + } + + String title, message; + switch (error) { + case ErrorSystemFiles: { + title = emulationActivity.getString(R.string.system_archive_not_found); + message = emulationActivity.getString(R.string.system_archive_not_found_message, details.isEmpty() ? emulationActivity.getString(R.string.system_archive_general) : details); + break; + } + case ErrorSavestate: { + title = emulationActivity.getString(R.string.save_load_error); + message = details; + break; + } + case ErrorUnknown: { + title = emulationActivity.getString(R.string.fatal_error); + message = emulationActivity.getString(R.string.fatal_error_message); + break; + } + default: { + return true; + } + } + + // Show the AlertDialog on the main thread. + emulationActivity.runOnUiThread(() -> OnCoreErrorImpl(title, message)); + + // Wait for the lock to notify that it is complete. + synchronized (coreErrorAlertLock) { + try { + coreErrorAlertLock.wait(); + } catch (Exception ignored) { + } + } + + return coreErrorAlertResult; + } + + public static boolean isPortraitMode() { + return CitraApplication.getAppContext().getResources().getConfiguration().orientation == + Configuration.ORIENTATION_PORTRAIT; + } + + public static int landscapeScreenLayout() { + return EmulationMenuSettings.getLandscapeScreenLayout(); + } + + public static boolean displayAlertMsg(final String caption, final String text, + final boolean yesNo) { + Log.error("[NativeLibrary] Alert: " + text); + final EmulationActivity emulationActivity = sEmulationActivity.get(); + boolean result = false; + if (emulationActivity == null) { + Log.warning("[NativeLibrary] EmulationActivity is null, can't do panic alert."); + } else { + // Create object used for waiting. + final Object lock = new Object(); + AlertDialog.Builder builder = new AlertDialog.Builder(emulationActivity) + .setTitle(caption) + .setMessage(text); + + // If not yes/no dialog just have one button that dismisses modal, + // otherwise have a yes and no button that sets alertResult accordingly. + if (!yesNo) { + builder + .setCancelable(false) + .setPositiveButton(android.R.string.ok, (dialog, whichButton) -> + { + dialog.dismiss(); + synchronized (lock) { + lock.notify(); + } + }); + } else { + alertResult = false; + + builder + .setPositiveButton(android.R.string.yes, (dialog, whichButton) -> + { + alertResult = true; + dialog.dismiss(); + synchronized (lock) { + lock.notify(); + } + }) + .setNegativeButton(android.R.string.no, (dialog, whichButton) -> + { + alertResult = false; + dialog.dismiss(); + synchronized (lock) { + lock.notify(); + } + }); + } + + // Show the AlertDialog on the main thread. + emulationActivity.runOnUiThread(builder::show); + + // Wait for the lock to notify that it is complete. + synchronized (lock) { + try { + lock.wait(); + } catch (Exception e) { + } + } + + if (yesNo) + result = alertResult; + } + return result; + } + + public static void retryDisplayAlertPrompt() { + if (!alertPromptInProgress) { + return; + } + displayAlertPromptImpl(alertPromptCaption, alertPromptEditText.getText().toString(), alertPromptButtonConfig).show(); + } + + public static String displayAlertPrompt(String caption, String text, int buttonConfig) { + alertPromptCaption = caption; + alertPromptButtonConfig = buttonConfig; + alertPromptInProgress = true; + + // Show the AlertDialog on the main thread + sEmulationActivity.get().runOnUiThread(() -> displayAlertPromptImpl(alertPromptCaption, text, alertPromptButtonConfig).show()); + + // Wait for the lock to notify that it is complete + synchronized (alertPromptLock) { + try { + alertPromptLock.wait(); + } catch (Exception e) { + } + } + alertPromptInProgress = false; + + return alertPromptResult; + } + + public static AlertDialog.Builder displayAlertPromptImpl(String caption, String text, int buttonConfig) { + final EmulationActivity emulationActivity = sEmulationActivity.get(); + alertPromptResult = ""; + alertPromptButton = 0; + + FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); + params.leftMargin = params.rightMargin = CitraApplication.getAppContext().getResources().getDimensionPixelSize(R.dimen.dialog_margin); + + // Set up the input + alertPromptEditText = new EditText(CitraApplication.getAppContext()); + alertPromptEditText.setText(text); + alertPromptEditText.setSingleLine(); + alertPromptEditText.setLayoutParams(params); + + FrameLayout container = new FrameLayout(emulationActivity); + container.addView(alertPromptEditText); + + AlertDialog.Builder builder = new AlertDialog.Builder(emulationActivity) + .setTitle(caption) + .setView(container) + .setPositiveButton(android.R.string.ok, (dialogInterface, i) -> + { + alertPromptButton = buttonConfig; + alertPromptResult = alertPromptEditText.getText().toString(); + synchronized (alertPromptLock) { + alertPromptLock.notifyAll(); + } + }) + .setOnDismissListener(dialogInterface -> + { + alertPromptResult = ""; + synchronized (alertPromptLock) { + alertPromptLock.notifyAll(); + } + }); + + if (buttonConfig > 0) { + builder.setNegativeButton(android.R.string.cancel, (dialogInterface, i) -> + { + alertPromptResult = ""; + synchronized (alertPromptLock) { + alertPromptLock.notifyAll(); + } + }); + } + + return builder; + } + + public static int alertPromptButton() { + return alertPromptButton; + } + + public static void exitEmulationActivity(int resultCode) { + final int Success = 0; + final int ErrorNotInitialized = 1; + final int ErrorGetLoader = 2; + final int ErrorSystemMode = 3; + final int ErrorLoader = 4; + final int ErrorLoader_ErrorEncrypted = 5; + final int ErrorLoader_ErrorInvalidFormat = 6; + final int ErrorSystemFiles = 7; + final int ErrorVideoCore = 8; + final int ErrorVideoCore_ErrorGenericDrivers = 9; + final int ErrorVideoCore_ErrorBelowGL33 = 10; + final int ShutdownRequested = 11; + final int ErrorUnknown = 12; + + final EmulationActivity emulationActivity = sEmulationActivity.get(); + if (emulationActivity == null) { + Log.warning("[NativeLibrary] EmulationActivity is null, can't exit."); + return; + } + + int captionId = R.string.loader_error_invalid_format; + if (resultCode == ErrorLoader_ErrorEncrypted) { + captionId = R.string.loader_error_encrypted; + } + + AlertDialog.Builder builder = new AlertDialog.Builder(emulationActivity) + .setTitle(captionId) + .setMessage(Html.fromHtml("Please follow the guides to redump your game cartidges or installed titles.", Html.FROM_HTML_MODE_LEGACY)) + .setPositiveButton(android.R.string.ok, (dialog, whichButton) -> emulationActivity.finish()) + .setOnDismissListener(dialogInterface -> emulationActivity.finish()); + emulationActivity.runOnUiThread(() -> { + AlertDialog alert = builder.create(); + alert.show(); + ((TextView) alert.findViewById(android.R.id.message)).setMovementMethod(LinkMovementMethod.getInstance()); + }); + } + + public static void setEmulationActivity(EmulationActivity emulationActivity) { + Log.verbose("[NativeLibrary] Registering EmulationActivity."); + sEmulationActivity = new WeakReference<>(emulationActivity); + } + + public static void clearEmulationActivity() { + Log.verbose("[NativeLibrary] Unregistering EmulationActivity."); + + sEmulationActivity.clear(); + } + + private static final Object cameraPermissionLock = new Object(); + private static boolean cameraPermissionGranted = false; + public static final int REQUEST_CODE_NATIVE_CAMERA = 800; + + public static boolean RequestCameraPermission() { + final EmulationActivity emulationActivity = sEmulationActivity.get(); + if (emulationActivity == null) { + Log.error("[NativeLibrary] EmulationActivity not present"); + return false; + } + if (ContextCompat.checkSelfPermission(emulationActivity, CAMERA) == PackageManager.PERMISSION_GRANTED) { + // Permission already granted + return true; + } + emulationActivity.requestPermissions(new String[]{CAMERA}, REQUEST_CODE_NATIVE_CAMERA); + + // Wait until result is returned + synchronized (cameraPermissionLock) { + try { + cameraPermissionLock.wait(); + } catch (InterruptedException ignored) { + } + } + return cameraPermissionGranted; + } + + public static void CameraPermissionResult(boolean granted) { + cameraPermissionGranted = granted; + synchronized (cameraPermissionLock) { + cameraPermissionLock.notify(); + } + } + + private static final Object micPermissionLock = new Object(); + private static boolean micPermissionGranted = false; + public static final int REQUEST_CODE_NATIVE_MIC = 900; + + public static boolean RequestMicPermission() { + final EmulationActivity emulationActivity = sEmulationActivity.get(); + if (emulationActivity == null) { + Log.error("[NativeLibrary] EmulationActivity not present"); + return false; + } + if (ContextCompat.checkSelfPermission(emulationActivity, RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) { + // Permission already granted + return true; + } + emulationActivity.requestPermissions(new String[]{RECORD_AUDIO}, REQUEST_CODE_NATIVE_MIC); + + // Wait until result is returned + synchronized (micPermissionLock) { + try { + micPermissionLock.wait(); + } catch (InterruptedException ignored) { + } + } + return micPermissionGranted; + } + + public static void MicPermissionResult(boolean granted) { + micPermissionGranted = granted; + synchronized (micPermissionLock) { + micPermissionLock.notify(); + } + } + + /// Notifies that the activity is now in foreground and camera devices can now be reloaded + public static native void ReloadCameraDevices(); + + public static native boolean LoadAmiibo(byte[] bytes); + + public static native void RemoveAmiibo(); + + public static native void InstallCIAS(String[] path); + + public static final int SAVESTATE_SLOT_COUNT = 10; + + public static final class SavestateInfo { + public int slot; + public Date time; + } + + @Nullable + public static native SavestateInfo[] GetSavestateInfo(); + + public static native void SaveState(int slot); + public static native void LoadState(int slot); + + /** + * Logs the Citra version, Android version and, CPU. + */ + public static native void LogDeviceInfo(); + + /** + * Button type for use in onTouchEvent + */ + public static final class ButtonType { + public static final int BUTTON_A = 700; + public static final int BUTTON_B = 701; + public static final int BUTTON_X = 702; + public static final int BUTTON_Y = 703; + public static final int BUTTON_START = 704; + public static final int BUTTON_SELECT = 705; + public static final int BUTTON_HOME = 706; + public static final int BUTTON_ZL = 707; + public static final int BUTTON_ZR = 708; + public static final int DPAD_UP = 709; + public static final int DPAD_DOWN = 710; + public static final int DPAD_LEFT = 711; + public static final int DPAD_RIGHT = 712; + public static final int STICK_LEFT = 713; + public static final int STICK_LEFT_UP = 714; + public static final int STICK_LEFT_DOWN = 715; + public static final int STICK_LEFT_LEFT = 716; + public static final int STICK_LEFT_RIGHT = 717; + public static final int STICK_C = 718; + public static final int STICK_C_UP = 719; + public static final int STICK_C_DOWN = 720; + public static final int STICK_C_LEFT = 771; + public static final int STICK_C_RIGHT = 772; + public static final int TRIGGER_L = 773; + public static final int TRIGGER_R = 774; + public static final int DPAD = 780; + public static final int BUTTON_DEBUG = 781; + public static final int BUTTON_GPIO14 = 782; + } + + /** + * Button states + */ + public static final class ButtonState { + public static final int RELEASED = 0; + public static final int PRESSED = 1; + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/activities/CustomFilePickerActivity.java b/src/android/app/src/main/java/org/citra/citra_emu/activities/CustomFilePickerActivity.java new file mode 100644 index 000000000..3083286e2 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/activities/CustomFilePickerActivity.java @@ -0,0 +1,38 @@ +package org.citra.citra_emu.activities; + +import android.content.Intent; +import android.os.Environment; + +import androidx.annotation.Nullable; + +import com.nononsenseapps.filepicker.AbstractFilePickerFragment; +import com.nononsenseapps.filepicker.FilePickerActivity; + +import org.citra.citra_emu.fragments.CustomFilePickerFragment; + +import java.io.File; + +public class CustomFilePickerActivity extends FilePickerActivity { + public static final String EXTRA_TITLE = "filepicker.intent.TITLE"; + public static final String EXTRA_EXTENSIONS = "filepicker.intent.EXTENSIONS"; + + @Override + protected AbstractFilePickerFragment getFragment( + @Nullable final String startPath, final int mode, final boolean allowMultiple, + final boolean allowCreateDir, final boolean allowExistingFile, + final boolean singleClick) { + CustomFilePickerFragment fragment = new CustomFilePickerFragment(); + // startPath is allowed to be null. In that case, default folder should be SD-card and not "/" + fragment.setArgs( + startPath != null ? startPath : Environment.getExternalStorageDirectory().getPath(), + mode, allowMultiple, allowCreateDir, allowExistingFile, singleClick); + + Intent intent = getIntent(); + int title = intent == null ? 0 : intent.getIntExtra(EXTRA_TITLE, 0); + fragment.setTitle(title); + String allowedExtensions = intent == null ? "*" : intent.getStringExtra(EXTRA_EXTENSIONS); + fragment.setAllowedExtensions(allowedExtensions); + + return fragment; + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/activities/EmulationActivity.java b/src/android/app/src/main/java/org/citra/citra_emu/activities/EmulationActivity.java new file mode 100644 index 000000000..adddcf110 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/activities/EmulationActivity.java @@ -0,0 +1,788 @@ +package org.citra.citra_emu.activities; + +import android.app.Activity; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.pm.PackageManager; +import android.os.Bundle; +import android.os.Handler; +import android.preference.PreferenceManager; +import android.util.SparseIntArray; +import android.view.InputDevice; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuItem; +import android.view.MotionEvent; +import android.view.SubMenu; +import android.view.View; +import android.widget.CheckBox; +import android.widget.SeekBar; +import android.widget.TextView; + +import androidx.annotation.IntDef; +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.app.NotificationManagerCompat; +import androidx.fragment.app.FragmentActivity; + +import org.citra.citra_emu.CitraApplication; +import org.citra.citra_emu.NativeLibrary; +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.settings.model.view.InputBindingSetting; +import org.citra.citra_emu.features.settings.ui.SettingsActivity; +import org.citra.citra_emu.features.settings.utils.SettingsFile; +import org.citra.citra_emu.camera.StillImageCameraHelper; +import org.citra.citra_emu.fragments.EmulationFragment; +import org.citra.citra_emu.ui.main.MainActivity; +import org.citra.citra_emu.utils.ControllerMappingHelper; +import org.citra.citra_emu.utils.EmulationMenuSettings; +import org.citra.citra_emu.utils.FileBrowserHelper; +import org.citra.citra_emu.utils.FileUtil; +import org.citra.citra_emu.utils.ForegroundService; + +import java.io.File; +import java.io.IOException; +import java.lang.annotation.Retention; +import java.util.Collections; +import java.util.List; + +import static android.Manifest.permission.CAMERA; +import static android.Manifest.permission.RECORD_AUDIO; +import static java.lang.annotation.RetentionPolicy.SOURCE; + +public final class EmulationActivity extends AppCompatActivity { + public static final String EXTRA_SELECTED_GAME = "SelectedGame"; + public static final String EXTRA_SELECTED_TITLE = "SelectedTitle"; + public static final int MENU_ACTION_EDIT_CONTROLS_PLACEMENT = 0; + public static final int MENU_ACTION_TOGGLE_CONTROLS = 1; + public static final int MENU_ACTION_ADJUST_SCALE = 2; + public static final int MENU_ACTION_EXIT = 3; + public static final int MENU_ACTION_SHOW_FPS = 4; + public static final int MENU_ACTION_SCREEN_LAYOUT_LANDSCAPE = 5; + public static final int MENU_ACTION_SCREEN_LAYOUT_PORTRAIT = 6; + public static final int MENU_ACTION_SCREEN_LAYOUT_SINGLE = 7; + public static final int MENU_ACTION_SCREEN_LAYOUT_SIDEBYSIDE = 8; + public static final int MENU_ACTION_SWAP_SCREENS = 9; + public static final int MENU_ACTION_RESET_OVERLAY = 10; + public static final int MENU_ACTION_SHOW_OVERLAY = 11; + public static final int MENU_ACTION_OPEN_SETTINGS = 12; + public static final int MENU_ACTION_LOAD_AMIIBO = 13; + public static final int MENU_ACTION_REMOVE_AMIIBO = 14; + public static final int MENU_ACTION_JOYSTICK_REL_CENTER = 15; + public static final int MENU_ACTION_DPAD_SLIDE_ENABLE = 16; + + public static final int REQUEST_SELECT_AMIIBO = 2; + private static final int EMULATION_RUNNING_NOTIFICATION = 0x1000; + private static SparseIntArray buttonsActionsMap = new SparseIntArray(); + + static { + buttonsActionsMap.append(R.id.menu_emulation_edit_layout, + EmulationActivity.MENU_ACTION_EDIT_CONTROLS_PLACEMENT); + buttonsActionsMap.append(R.id.menu_emulation_toggle_controls, + EmulationActivity.MENU_ACTION_TOGGLE_CONTROLS); + buttonsActionsMap + .append(R.id.menu_emulation_adjust_scale, EmulationActivity.MENU_ACTION_ADJUST_SCALE); + buttonsActionsMap.append(R.id.menu_emulation_show_fps, + EmulationActivity.MENU_ACTION_SHOW_FPS); + buttonsActionsMap.append(R.id.menu_screen_layout_landscape, + EmulationActivity.MENU_ACTION_SCREEN_LAYOUT_LANDSCAPE); + buttonsActionsMap.append(R.id.menu_screen_layout_portrait, + EmulationActivity.MENU_ACTION_SCREEN_LAYOUT_PORTRAIT); + buttonsActionsMap.append(R.id.menu_screen_layout_single, + EmulationActivity.MENU_ACTION_SCREEN_LAYOUT_SINGLE); + buttonsActionsMap.append(R.id.menu_screen_layout_sidebyside, + EmulationActivity.MENU_ACTION_SCREEN_LAYOUT_SIDEBYSIDE); + buttonsActionsMap.append(R.id.menu_emulation_swap_screens, + EmulationActivity.MENU_ACTION_SWAP_SCREENS); + buttonsActionsMap + .append(R.id.menu_emulation_reset_overlay, EmulationActivity.MENU_ACTION_RESET_OVERLAY); + buttonsActionsMap + .append(R.id.menu_emulation_show_overlay, EmulationActivity.MENU_ACTION_SHOW_OVERLAY); + buttonsActionsMap + .append(R.id.menu_emulation_open_settings, EmulationActivity.MENU_ACTION_OPEN_SETTINGS); + buttonsActionsMap + .append(R.id.menu_emulation_amiibo_load, EmulationActivity.MENU_ACTION_LOAD_AMIIBO); + buttonsActionsMap + .append(R.id.menu_emulation_amiibo_remove, EmulationActivity.MENU_ACTION_REMOVE_AMIIBO); + buttonsActionsMap.append(R.id.menu_emulation_joystick_rel_center, + EmulationActivity.MENU_ACTION_JOYSTICK_REL_CENTER); + buttonsActionsMap.append(R.id.menu_emulation_dpad_slide_enable, + EmulationActivity.MENU_ACTION_DPAD_SLIDE_ENABLE); + } + + private View mDecorView; + private EmulationFragment mEmulationFragment; + private SharedPreferences mPreferences; + private ControllerMappingHelper mControllerMappingHelper; + private Intent foregroundService; + private boolean activityRecreated; + private String mSelectedTitle; + private String mPath; + + public static void launch(FragmentActivity activity, String path, String title) { + Intent launcher = new Intent(activity, EmulationActivity.class); + + launcher.putExtra(EXTRA_SELECTED_GAME, path); + launcher.putExtra(EXTRA_SELECTED_TITLE, title); + activity.startActivity(launcher); + } + + public static void tryDismissRunningNotification(Activity activity) { + NotificationManagerCompat.from(activity).cancel(EMULATION_RUNNING_NOTIFICATION); + } + + @Override + protected void onDestroy() { + stopService(foregroundService); + super.onDestroy(); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + if (savedInstanceState == null) { + // Get params we were passed + Intent gameToEmulate = getIntent(); + mPath = gameToEmulate.getStringExtra(EXTRA_SELECTED_GAME); + mSelectedTitle = gameToEmulate.getStringExtra(EXTRA_SELECTED_TITLE); + activityRecreated = false; + } else { + activityRecreated = true; + restoreState(savedInstanceState); + } + + mControllerMappingHelper = new ControllerMappingHelper(); + + // Get a handle to the Window containing the UI. + mDecorView = getWindow().getDecorView(); + mDecorView.setOnSystemUiVisibilityChangeListener(visibility -> + { + if ((visibility & View.SYSTEM_UI_FLAG_FULLSCREEN) == 0) { + // Go back to immersive fullscreen mode in 3s + Handler handler = new Handler(getMainLooper()); + handler.postDelayed(this::enableFullscreenImmersive, 3000 /* 3s */); + } + }); + // Set these options now so that the SurfaceView the game renders into is the right size. + enableFullscreenImmersive(); + + setTheme(R.style.CitraEmulationBase); + + setContentView(R.layout.activity_emulation); + + // Find or create the EmulationFragment + mEmulationFragment = (EmulationFragment) getSupportFragmentManager() + .findFragmentById(R.id.frame_emulation_fragment); + if (mEmulationFragment == null) { + mEmulationFragment = EmulationFragment.newInstance(mPath); + getSupportFragmentManager().beginTransaction() + .add(R.id.frame_emulation_fragment, mEmulationFragment) + .commit(); + } + + setTitle(mSelectedTitle); + + mPreferences = PreferenceManager.getDefaultSharedPreferences(this); + + // Start a foreground service to prevent the app from getting killed in the background + foregroundService = new Intent(EmulationActivity.this, ForegroundService.class); + startForegroundService(foregroundService); + + // Override Citra core INI with the one set by our in game menu + NativeLibrary.SwapScreens(EmulationMenuSettings.getSwapScreens(), + getWindowManager().getDefaultDisplay().getRotation()); + } + + @Override + protected void onSaveInstanceState(@NonNull Bundle outState) { + outState.putString(EXTRA_SELECTED_GAME, mPath); + outState.putString(EXTRA_SELECTED_TITLE, mSelectedTitle); + super.onSaveInstanceState(outState); + } + + protected void restoreState(Bundle savedInstanceState) { + mPath = savedInstanceState.getString(EXTRA_SELECTED_GAME); + mSelectedTitle = savedInstanceState.getString(EXTRA_SELECTED_TITLE); + + // If an alert prompt was in progress when state was restored, retry displaying it + NativeLibrary.retryDisplayAlertPrompt(); + } + + @Override + public void onRestart() { + super.onRestart(); + NativeLibrary.ReloadCameraDevices(); + } + + @Override + public void onBackPressed() { + NativeLibrary.PauseEmulation(); + new AlertDialog.Builder(this) + .setTitle(R.string.emulation_close_game) + .setMessage(R.string.emulation_close_game_message) + .setPositiveButton(android.R.string.yes, (dialogInterface, i) -> + { + mEmulationFragment.stopEmulation(); + finish(); + }) + .setNegativeButton(android.R.string.cancel, (dialogInterface, i) -> + NativeLibrary.UnPauseEmulation()) + .setOnCancelListener(dialogInterface -> + NativeLibrary.UnPauseEmulation()) + .create() + .show(); + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + switch (requestCode) { + case NativeLibrary.REQUEST_CODE_NATIVE_CAMERA: + if (grantResults[0] != PackageManager.PERMISSION_GRANTED && + shouldShowRequestPermissionRationale(CAMERA)) { + new AlertDialog.Builder(this) + .setTitle(R.string.camera) + .setMessage(R.string.camera_permission_needed) + .setPositiveButton(android.R.string.ok, null) + .show(); + } + NativeLibrary.CameraPermissionResult(grantResults[0] == PackageManager.PERMISSION_GRANTED); + break; + case NativeLibrary.REQUEST_CODE_NATIVE_MIC: + if (grantResults[0] != PackageManager.PERMISSION_GRANTED && + shouldShowRequestPermissionRationale(RECORD_AUDIO)) { + new AlertDialog.Builder(this) + .setTitle(R.string.microphone) + .setMessage(R.string.microphone_permission_needed) + .setPositiveButton(android.R.string.ok, null) + .show(); + } + NativeLibrary.MicPermissionResult(grantResults[0] == PackageManager.PERMISSION_GRANTED); + break; + default: + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + break; + } + } + + private void enableFullscreenImmersive() { + // It would be nice to use IMMERSIVE_STICKY, but that doesn't show the toolbar. + mDecorView.setSystemUiVisibility( + View.SYSTEM_UI_FLAG_LAYOUT_STABLE | + View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | + View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | + View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | + View.SYSTEM_UI_FLAG_FULLSCREEN | + View.SYSTEM_UI_FLAG_IMMERSIVE); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + // Inflate the menu; this adds items to the action bar if it is present. + getMenuInflater().inflate(R.menu.menu_emulation, menu); + + int layoutOptionMenuItem = R.id.menu_screen_layout_landscape; + switch (EmulationMenuSettings.getLandscapeScreenLayout()) { + case EmulationMenuSettings.LayoutOption_SingleScreen: + layoutOptionMenuItem = R.id.menu_screen_layout_single; + break; + case EmulationMenuSettings.LayoutOption_SideScreen: + layoutOptionMenuItem = R.id.menu_screen_layout_sidebyside; + break; + case EmulationMenuSettings.LayoutOption_MobilePortrait: + layoutOptionMenuItem = R.id.menu_screen_layout_portrait; + break; + } + + menu.findItem(layoutOptionMenuItem).setChecked(true); + menu.findItem(R.id.menu_emulation_joystick_rel_center).setChecked(EmulationMenuSettings.getJoystickRelCenter()); + menu.findItem(R.id.menu_emulation_dpad_slide_enable).setChecked(EmulationMenuSettings.getDpadSlideEnable()); + menu.findItem(R.id.menu_emulation_show_fps).setChecked(EmulationMenuSettings.getShowFps()); + menu.findItem(R.id.menu_emulation_swap_screens).setChecked(EmulationMenuSettings.getSwapScreens()); + menu.findItem(R.id.menu_emulation_show_overlay).setChecked(EmulationMenuSettings.getShowOverlay()); + + return true; + } + + private void DisplaySavestateWarning() { + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext()); + if (preferences.getBoolean("savestateWarningShown", false)) { + return; + } + + LayoutInflater inflater = mEmulationFragment.requireActivity().getLayoutInflater(); + View view = inflater.inflate(R.layout.dialog_checkbox, null); + CheckBox checkBox = view.findViewById(R.id.checkBox); + + new AlertDialog.Builder(this) + .setTitle(R.string.savestate_warning_title) + .setMessage(R.string.savestate_warning_message) + .setView(view) + .setPositiveButton(android.R.string.ok, (dialog, which) -> { + preferences.edit().putBoolean("savestateWarningShown", checkBox.isChecked()).apply(); + }) + .show(); + } + + @Override + public boolean onPrepareOptionsMenu(Menu menu) { + super.onPrepareOptionsMenu(menu); + + final NativeLibrary.SavestateInfo[] savestates = NativeLibrary.GetSavestateInfo(); + if (savestates == null) { + menu.findItem(R.id.menu_emulation_save_state).setVisible(false); + menu.findItem(R.id.menu_emulation_load_state).setVisible(false); + return true; + } + menu.findItem(R.id.menu_emulation_save_state).setVisible(true); + menu.findItem(R.id.menu_emulation_load_state).setVisible(true); + + final SubMenu saveStateMenu = menu.findItem(R.id.menu_emulation_save_state).getSubMenu(); + final SubMenu loadStateMenu = menu.findItem(R.id.menu_emulation_load_state).getSubMenu(); + saveStateMenu.clear(); + loadStateMenu.clear(); + + // Update savestates information + for (int i = 0; i < NativeLibrary.SAVESTATE_SLOT_COUNT; ++i) { + final int slot = i + 1; + final String text = getString(R.string.emulation_empty_state_slot, slot); + saveStateMenu.add(text).setEnabled(true).setOnMenuItemClickListener((item) -> { + DisplaySavestateWarning(); + NativeLibrary.SaveState(slot); + return true; + }); + loadStateMenu.add(text).setEnabled(false).setOnMenuItemClickListener((item) -> { + NativeLibrary.LoadState(slot); + return true; + }); + } + for (final NativeLibrary.SavestateInfo info : savestates) { + final String text = getString(R.string.emulation_occupied_state_slot, info.slot, info.time); + saveStateMenu.getItem(info.slot - 1).setTitle(text); + loadStateMenu.getItem(info.slot - 1).setTitle(text).setEnabled(true); + } + return true; + } + + @SuppressWarnings("WrongConstant") + @Override + public boolean onOptionsItemSelected(MenuItem item) { + int action = buttonsActionsMap.get(item.getItemId(), -1); + + switch (action) { + // Edit the placement of the controls + case MENU_ACTION_EDIT_CONTROLS_PLACEMENT: + editControlsPlacement(); + break; + + // Enable/Disable specific buttons or the entire input overlay. + case MENU_ACTION_TOGGLE_CONTROLS: + toggleControls(); + break; + + // Adjust the scale of the overlay controls. + case MENU_ACTION_ADJUST_SCALE: + adjustScale(); + break; + + // Toggle the visibility of the Performance stats TextView + case MENU_ACTION_SHOW_FPS: { + final boolean isEnabled = !EmulationMenuSettings.getShowFps(); + EmulationMenuSettings.setShowFps(isEnabled); + item.setChecked(isEnabled); + + mEmulationFragment.updateShowFpsOverlay(); + break; + } + // Sets the screen layout to Landscape + case MENU_ACTION_SCREEN_LAYOUT_LANDSCAPE: + changeScreenOrientation(EmulationMenuSettings.LayoutOption_MobileLandscape, item); + break; + + // Sets the screen layout to Portrait + case MENU_ACTION_SCREEN_LAYOUT_PORTRAIT: + changeScreenOrientation(EmulationMenuSettings.LayoutOption_MobilePortrait, item); + break; + + // Sets the screen layout to Single + case MENU_ACTION_SCREEN_LAYOUT_SINGLE: + changeScreenOrientation(EmulationMenuSettings.LayoutOption_SingleScreen, item); + break; + + // Sets the screen layout to Side by Side + case MENU_ACTION_SCREEN_LAYOUT_SIDEBYSIDE: + changeScreenOrientation(EmulationMenuSettings.LayoutOption_SideScreen, item); + break; + + // Swap the top and bottom screen locations + case MENU_ACTION_SWAP_SCREENS: { + final boolean isEnabled = !EmulationMenuSettings.getSwapScreens(); + EmulationMenuSettings.setSwapScreens(isEnabled); + item.setChecked(isEnabled); + + NativeLibrary.SwapScreens(isEnabled, getWindowManager().getDefaultDisplay() + .getRotation()); + break; + } + + // Reset overlay placement + case MENU_ACTION_RESET_OVERLAY: + resetOverlay(); + break; + + // Show or hide overlay + case MENU_ACTION_SHOW_OVERLAY: { + final boolean isEnabled = !EmulationMenuSettings.getShowOverlay(); + EmulationMenuSettings.setShowOverlay(isEnabled); + item.setChecked(isEnabled); + + mEmulationFragment.refreshInputOverlay(); + break; + } + + case MENU_ACTION_EXIT: + mEmulationFragment.stopEmulation(); + finish(); + break; + + case MENU_ACTION_OPEN_SETTINGS: + SettingsActivity.launch(this, SettingsFile.FILE_NAME_CONFIG, ""); + break; + + case MENU_ACTION_LOAD_AMIIBO: + FileBrowserHelper.openFilePicker(this, REQUEST_SELECT_AMIIBO, + R.string.select_amiibo, + Collections.singletonList("bin"), false); + break; + + case MENU_ACTION_REMOVE_AMIIBO: + RemoveAmiibo(); + break; + + case MENU_ACTION_JOYSTICK_REL_CENTER: + final boolean isJoystickRelCenterEnabled = !EmulationMenuSettings.getJoystickRelCenter(); + EmulationMenuSettings.setJoystickRelCenter(isJoystickRelCenterEnabled); + item.setChecked(isJoystickRelCenterEnabled); + break; + case MENU_ACTION_DPAD_SLIDE_ENABLE: + final boolean isDpadSlideEnabled = !EmulationMenuSettings.getDpadSlideEnable(); + EmulationMenuSettings.setDpadSlideEnable(isDpadSlideEnabled); + item.setChecked(isDpadSlideEnabled); + break; + } + + return true; + } + + private void changeScreenOrientation(int layoutOption, MenuItem item) { + item.setChecked(true); + NativeLibrary.NotifyOrientationChange(layoutOption, getWindowManager().getDefaultDisplay() + .getRotation()); + EmulationMenuSettings.setLandscapeScreenLayout(layoutOption); + } + + private void editControlsPlacement() { + if (mEmulationFragment.isConfiguringControls()) { + mEmulationFragment.stopConfiguringControls(); + } else { + mEmulationFragment.startConfiguringControls(); + } + } + + // Gets button presses + @Override + public boolean dispatchKeyEvent(KeyEvent event) { + int action; + int button = mPreferences.getInt(InputBindingSetting.getInputButtonKey(event.getKeyCode()), event.getKeyCode()); + + switch (event.getAction()) { + case KeyEvent.ACTION_DOWN: + // Handling the case where the back button is pressed. + if (event.getKeyCode() == KeyEvent.KEYCODE_BACK) { + onBackPressed(); + return true; + } + + // Normal key events. + action = NativeLibrary.ButtonState.PRESSED; + break; + case KeyEvent.ACTION_UP: + action = NativeLibrary.ButtonState.RELEASED; + break; + default: + return false; + } + InputDevice input = event.getDevice(); + + if (input == null) { + // Controller was disconnected + return false; + } + + return NativeLibrary.onGamePadEvent(input.getDescriptor(), button, action); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent result) { + super.onActivityResult(requestCode, resultCode, result); + switch (requestCode) { + case StillImageCameraHelper.REQUEST_CAMERA_FILE_PICKER: + StillImageCameraHelper.OnFilePickerResult(resultCode == RESULT_OK ? result : null); + break; + case REQUEST_SELECT_AMIIBO: + // If the user picked a file, as opposed to just backing out. + if (resultCode == MainActivity.RESULT_OK) { + String[] selectedFiles = FileBrowserHelper.getSelectedFiles(result); + if (selectedFiles == null) + return; + + onAmiiboSelected(selectedFiles[0]); + } + break; + } + } + + private void onAmiiboSelected(String selectedFile) { + File file = new File(selectedFile); + boolean success = false; + try { + byte[] bytes = FileUtil.getBytesFromFile(file); + success = NativeLibrary.LoadAmiibo(bytes); + } catch (IOException e) { + e.printStackTrace(); + } + + if (!success) { + new AlertDialog.Builder(this) + .setTitle(R.string.amiibo_load_error) + .setMessage(R.string.amiibo_load_error_message) + .setPositiveButton(android.R.string.ok, null) + .create() + .show(); + } + } + + private void RemoveAmiibo() { + NativeLibrary.RemoveAmiibo(); + } + + private void toggleControls() { + final SharedPreferences.Editor editor = mPreferences.edit(); + boolean[] enabledButtons = new boolean[14]; + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle(R.string.emulation_toggle_controls); + + for (int i = 0; i < enabledButtons.length; i++) { + // Buttons that are disabled by default + boolean defaultValue = true; + switch (i) { + case 6: // ZL + case 7: // ZR + case 12: // C-stick + defaultValue = false; + break; + } + + enabledButtons[i] = mPreferences.getBoolean("buttonToggle" + i, defaultValue); + } + builder.setMultiChoiceItems(R.array.n3dsButtons, enabledButtons, + (dialog, indexSelected, isChecked) -> editor + .putBoolean("buttonToggle" + indexSelected, isChecked)); + builder.setPositiveButton(android.R.string.ok, (dialogInterface, i) -> + { + editor.apply(); + + mEmulationFragment.refreshInputOverlay(); + }); + + AlertDialog alertDialog = builder.create(); + alertDialog.show(); + } + + private void adjustScale() { + LayoutInflater inflater = LayoutInflater.from(this); + View view = inflater.inflate(R.layout.dialog_seekbar, null); + + final SeekBar seekbar = view.findViewById(R.id.seekbar); + final TextView value = view.findViewById(R.id.text_value); + final TextView units = view.findViewById(R.id.text_units); + + seekbar.setMax(150); + seekbar.setProgress(mPreferences.getInt("controlScale", 50)); + seekbar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { + public void onStartTrackingTouch(SeekBar seekBar) { + } + + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + value.setText(String.valueOf(progress + 50)); + } + + public void onStopTrackingTouch(SeekBar seekBar) { + setControlScale(seekbar.getProgress()); + } + }); + + value.setText(String.valueOf(seekbar.getProgress() + 50)); + units.setText("%"); + + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle(R.string.emulation_control_scale); + builder.setView(view); + final int previousProgress = seekbar.getProgress(); + builder.setNegativeButton(android.R.string.cancel, (dialogInterface, i) -> { + setControlScale(previousProgress); + }); + builder.setPositiveButton(android.R.string.ok, (dialogInterface, i) -> + { + setControlScale(seekbar.getProgress()); + }); + builder.setNeutralButton(R.string.slider_default, (dialogInterface, i) -> { + setControlScale(50); + }); + + AlertDialog alertDialog = builder.create(); + alertDialog.show(); + } + + private void setControlScale(int scale) { + SharedPreferences.Editor editor = mPreferences.edit(); + editor.putInt("controlScale", scale); + editor.apply(); + mEmulationFragment.refreshInputOverlay(); + } + + private void resetOverlay() { + new AlertDialog.Builder(this) + .setTitle(getString(R.string.emulation_touch_overlay_reset)) + .setPositiveButton(android.R.string.yes, (dialogInterface, i) -> mEmulationFragment.resetInputOverlay()) + .setNegativeButton(android.R.string.cancel, (dialogInterface, i) -> { + }) + .create() + .show(); + } + + @Override + public boolean dispatchGenericMotionEvent(MotionEvent event) { + if (((event.getSource() & InputDevice.SOURCE_CLASS_JOYSTICK) == 0)) { + return super.dispatchGenericMotionEvent(event); + } + + // Don't attempt to do anything if we are disconnecting a device. + if (event.getActionMasked() == MotionEvent.ACTION_CANCEL) { + return true; + } + + InputDevice input = event.getDevice(); + List motions = input.getMotionRanges(); + + float[] axisValuesCirclePad = {0.0f, 0.0f}; + float[] axisValuesCStick = {0.0f, 0.0f}; + float[] axisValuesDPad = {0.0f, 0.0f}; + boolean isTriggerPressedLMapped = false; + boolean isTriggerPressedRMapped = false; + boolean isTriggerPressedZLMapped = false; + boolean isTriggerPressedZRMapped = false; + boolean isTriggerPressedL = false; + boolean isTriggerPressedR = false; + boolean isTriggerPressedZL = false; + boolean isTriggerPressedZR = false; + + for (InputDevice.MotionRange range : motions) { + int axis = range.getAxis(); + float origValue = event.getAxisValue(axis); + float value = mControllerMappingHelper.scaleAxis(input, axis, origValue); + int nextMapping = mPreferences.getInt(InputBindingSetting.getInputAxisButtonKey(axis), -1); + int guestOrientation = mPreferences.getInt(InputBindingSetting.getInputAxisOrientationKey(axis), -1); + + if (nextMapping == -1 || guestOrientation == -1) { + // Axis is unmapped + continue; + } + + if ((value > 0.f && value < 0.1f) || (value < 0.f && value > -0.1f)) { + // Skip joystick wobble + value = 0.f; + } + + if (nextMapping == NativeLibrary.ButtonType.STICK_LEFT) { + axisValuesCirclePad[guestOrientation] = value; + } else if (nextMapping == NativeLibrary.ButtonType.STICK_C) { + axisValuesCStick[guestOrientation] = value; + } else if (nextMapping == NativeLibrary.ButtonType.DPAD) { + axisValuesDPad[guestOrientation] = value; + } else if (nextMapping == NativeLibrary.ButtonType.TRIGGER_L) { + isTriggerPressedLMapped = true; + isTriggerPressedL = value != 0.f; + } else if (nextMapping == NativeLibrary.ButtonType.TRIGGER_R) { + isTriggerPressedRMapped = true; + isTriggerPressedR = value != 0.f; + } else if (nextMapping == NativeLibrary.ButtonType.BUTTON_ZL) { + isTriggerPressedZLMapped = true; + isTriggerPressedZL = value != 0.f; + } else if (nextMapping == NativeLibrary.ButtonType.BUTTON_ZR) { + isTriggerPressedZRMapped = true; + isTriggerPressedZR = value != 0.f; + } + } + + // Circle-Pad and C-Stick status + NativeLibrary.onGamePadMoveEvent(input.getDescriptor(), NativeLibrary.ButtonType.STICK_LEFT, axisValuesCirclePad[0], axisValuesCirclePad[1]); + NativeLibrary.onGamePadMoveEvent(input.getDescriptor(), NativeLibrary.ButtonType.STICK_C, axisValuesCStick[0], axisValuesCStick[1]); + + // Triggers L/R and ZL/ZR + if (isTriggerPressedLMapped) { + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.TRIGGER_L, isTriggerPressedL ? NativeLibrary.ButtonState.PRESSED : NativeLibrary.ButtonState.RELEASED); + } + if (isTriggerPressedRMapped) { + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.TRIGGER_R, isTriggerPressedR ? NativeLibrary.ButtonState.PRESSED : NativeLibrary.ButtonState.RELEASED); + } + if (isTriggerPressedZLMapped) { + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.BUTTON_ZL, isTriggerPressedZL ? NativeLibrary.ButtonState.PRESSED : NativeLibrary.ButtonState.RELEASED); + } + if (isTriggerPressedZRMapped) { + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.BUTTON_ZR, isTriggerPressedZR ? NativeLibrary.ButtonState.PRESSED : NativeLibrary.ButtonState.RELEASED); + } + + // Work-around to allow D-pad axis to be bound to emulated buttons + if (axisValuesDPad[0] == 0.f) { + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_LEFT, NativeLibrary.ButtonState.RELEASED); + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_RIGHT, NativeLibrary.ButtonState.RELEASED); + } + if (axisValuesDPad[0] < 0.f) { + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_LEFT, NativeLibrary.ButtonState.PRESSED); + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_RIGHT, NativeLibrary.ButtonState.RELEASED); + } + if (axisValuesDPad[0] > 0.f) { + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_LEFT, NativeLibrary.ButtonState.RELEASED); + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_RIGHT, NativeLibrary.ButtonState.PRESSED); + } + if (axisValuesDPad[1] == 0.f) { + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_UP, NativeLibrary.ButtonState.RELEASED); + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_DOWN, NativeLibrary.ButtonState.RELEASED); + } + if (axisValuesDPad[1] < 0.f) { + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_UP, NativeLibrary.ButtonState.PRESSED); + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_DOWN, NativeLibrary.ButtonState.RELEASED); + } + if (axisValuesDPad[1] > 0.f) { + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_UP, NativeLibrary.ButtonState.RELEASED); + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_DOWN, NativeLibrary.ButtonState.PRESSED); + } + + return true; + } + + public boolean isActivityRecreated() { + return activityRecreated; + } + + @Retention(SOURCE) + @IntDef({MENU_ACTION_EDIT_CONTROLS_PLACEMENT, MENU_ACTION_TOGGLE_CONTROLS, MENU_ACTION_ADJUST_SCALE, + MENU_ACTION_EXIT, MENU_ACTION_SHOW_FPS, MENU_ACTION_SCREEN_LAYOUT_LANDSCAPE, + MENU_ACTION_SCREEN_LAYOUT_PORTRAIT, MENU_ACTION_SCREEN_LAYOUT_SINGLE, MENU_ACTION_SCREEN_LAYOUT_SIDEBYSIDE, + MENU_ACTION_SWAP_SCREENS, MENU_ACTION_RESET_OVERLAY, MENU_ACTION_SHOW_OVERLAY, MENU_ACTION_OPEN_SETTINGS}) + public @interface MenuAction { + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/adapters/GameAdapter.java b/src/android/app/src/main/java/org/citra/citra_emu/adapters/GameAdapter.java new file mode 100644 index 000000000..bc791638a --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/adapters/GameAdapter.java @@ -0,0 +1,247 @@ +package org.citra.citra_emu.adapters; + +import android.database.Cursor; +import android.database.DataSetObserver; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.os.Build; +import android.os.SystemClock; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; +import androidx.core.content.ContextCompat; +import androidx.fragment.app.FragmentActivity; +import androidx.recyclerview.widget.RecyclerView; + +import org.citra.citra_emu.R; +import org.citra.citra_emu.activities.EmulationActivity; +import org.citra.citra_emu.model.GameDatabase; +import org.citra.citra_emu.ui.DividerItemDecoration; +import org.citra.citra_emu.utils.Log; +import org.citra.citra_emu.utils.PicassoUtils; +import org.citra.citra_emu.viewholders.GameViewHolder; + +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.stream.Stream; + +/** + * This adapter gets its information from a database Cursor. This fact, paired with the usage of + * ContentProviders and Loaders, allows for efficient display of a limited view into a (possibly) + * large dataset. + */ +public final class GameAdapter extends RecyclerView.Adapter implements + View.OnClickListener { + private Cursor mCursor; + private GameDataSetObserver mObserver; + + private boolean mDatasetValid; + private long mLastClickTime = 0; + + /** + * Initializes the adapter's observer, which watches for changes to the dataset. The adapter will + * display no data until a Cursor is supplied by a CursorLoader. + */ + public GameAdapter() { + mDatasetValid = false; + mObserver = new GameDataSetObserver(); + } + + /** + * Called by the LayoutManager when it is necessary to create a new view. + * + * @param parent The RecyclerView (I think?) the created view will be thrown into. + * @param viewType Not used here, but useful when more than one type of child will be used in the RecyclerView. + * @return The created ViewHolder with references to all the child view's members. + */ + @Override + public GameViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + // Create a new view. + View gameCard = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.card_game, parent, false); + + gameCard.setOnClickListener(this); + + // Use that view to create a ViewHolder. + return new GameViewHolder(gameCard); + } + + /** + * Called by the LayoutManager when a new view is not necessary because we can recycle + * an existing one (for example, if a view just scrolled onto the screen from the bottom, we + * can use the view that just scrolled off the top instead of inflating a new one.) + * + * @param holder A ViewHolder representing the view we're recycling. + * @param position The position of the 'new' view in the dataset. + */ + @RequiresApi(api = Build.VERSION_CODES.O) + @Override + public void onBindViewHolder(@NonNull GameViewHolder holder, int position) { + if (mDatasetValid) { + if (mCursor.moveToPosition(position)) { + PicassoUtils.loadGameIcon(holder.imageIcon, + mCursor.getString(GameDatabase.GAME_COLUMN_PATH)); + + holder.textGameTitle.setText(mCursor.getString(GameDatabase.GAME_COLUMN_TITLE).replaceAll("[\\t\\n\\r]+", " ")); + holder.textCompany.setText(mCursor.getString(GameDatabase.GAME_COLUMN_COMPANY)); + + final Path gamePath = Paths.get(mCursor.getString(GameDatabase.GAME_COLUMN_PATH)); + holder.textFileName.setText(gamePath.getFileName().toString()); + + // TODO These shouldn't be necessary once the move to a DB-based model is complete. + holder.gameId = mCursor.getString(GameDatabase.GAME_COLUMN_GAME_ID); + holder.path = mCursor.getString(GameDatabase.GAME_COLUMN_PATH); + holder.title = mCursor.getString(GameDatabase.GAME_COLUMN_TITLE); + holder.description = mCursor.getString(GameDatabase.GAME_COLUMN_DESCRIPTION); + holder.regions = mCursor.getString(GameDatabase.GAME_COLUMN_REGIONS); + holder.company = mCursor.getString(GameDatabase.GAME_COLUMN_COMPANY); + + final int backgroundColorId = isValidGame(holder.path) ? R.color.card_view_background : R.color.card_view_disabled; + View itemView = holder.getItemView(); + itemView.setBackgroundColor(ContextCompat.getColor(itemView.getContext(), backgroundColorId)); + } else { + Log.error("[GameAdapter] Can't bind view; Cursor is not valid."); + } + } else { + Log.error("[GameAdapter] Can't bind view; dataset is not valid."); + } + } + + /** + * Called by the LayoutManager to find out how much data we have. + * + * @return Size of the dataset. + */ + @Override + public int getItemCount() { + if (mDatasetValid && mCursor != null) { + return mCursor.getCount(); + } + Log.error("[GameAdapter] Dataset is not valid."); + return 0; + } + + /** + * Return the contents of the _id column for a given row. + * + * @param position The row for which Android wants an ID. + * @return A valid ID from the database, or 0 if not available. + */ + @Override + public long getItemId(int position) { + if (mDatasetValid && mCursor != null) { + if (mCursor.moveToPosition(position)) { + return mCursor.getLong(GameDatabase.COLUMN_DB_ID); + } + } + + Log.error("[GameAdapter] Dataset is not valid."); + return 0; + } + + /** + * Tell Android whether or not each item in the dataset has a stable identifier. + * Which it does, because it's a database, so always tell Android 'true'. + * + * @param hasStableIds ignored. + */ + @Override + public void setHasStableIds(boolean hasStableIds) { + super.setHasStableIds(true); + } + + /** + * When a load is finished, call this to replace the existing data with the newly-loaded + * data. + * + * @param cursor The newly-loaded Cursor. + */ + public void swapCursor(Cursor cursor) { + // Sanity check. + if (cursor == mCursor) { + return; + } + + // Before getting rid of the old cursor, disassociate it from the Observer. + final Cursor oldCursor = mCursor; + if (oldCursor != null && mObserver != null) { + oldCursor.unregisterDataSetObserver(mObserver); + } + + mCursor = cursor; + if (mCursor != null) { + // Attempt to associate the new Cursor with the Observer. + if (mObserver != null) { + mCursor.registerDataSetObserver(mObserver); + } + + mDatasetValid = true; + } else { + mDatasetValid = false; + } + + notifyDataSetChanged(); + } + + /** + * Launches the game that was clicked on. + * + * @param view The card representing the game the user wants to play. + */ + @Override + public void onClick(View view) { + // Double-click prevention, using threshold of 1000 ms + if (SystemClock.elapsedRealtime() - mLastClickTime < 1000) { + return; + } + mLastClickTime = SystemClock.elapsedRealtime(); + + GameViewHolder holder = (GameViewHolder) view.getTag(); + + EmulationActivity.launch((FragmentActivity) view.getContext(), holder.path, holder.title); + } + + public static class SpacesItemDecoration extends DividerItemDecoration { + private int space; + + public SpacesItemDecoration(Drawable divider, int space) { + super(divider); + this.space = space; + } + + @Override + public void getItemOffsets(Rect outRect, @NonNull View view, @NonNull RecyclerView parent, + @NonNull RecyclerView.State state) { + outRect.left = 0; + outRect.right = 0; + outRect.bottom = space; + outRect.top = 0; + } + } + + private boolean isValidGame(String path) { + return Stream.of( + ".rar", ".zip", ".7z", ".torrent", ".tar", ".gz").noneMatch(suffix -> path.toLowerCase().endsWith(suffix)); + } + + private final class GameDataSetObserver extends DataSetObserver { + @Override + public void onChanged() { + super.onChanged(); + + mDatasetValid = true; + notifyDataSetChanged(); + } + + @Override + public void onInvalidated() { + super.onInvalidated(); + + mDatasetValid = false; + notifyDataSetChanged(); + } + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/applets/MiiSelector.java b/src/android/app/src/main/java/org/citra/citra_emu/applets/MiiSelector.java new file mode 100644 index 000000000..85b55b00d --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/applets/MiiSelector.java @@ -0,0 +1,124 @@ +// Copyright 2020 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.applets; + +import android.app.Activity; +import android.app.Dialog; +import android.content.DialogInterface; +import android.os.Bundle; + +import org.citra.citra_emu.NativeLibrary; +import org.citra.citra_emu.R; +import org.citra.citra_emu.activities.EmulationActivity; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Objects; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.DialogFragment; + +public final class MiiSelector { + public static class MiiSelectorConfig implements java.io.Serializable { + public boolean enable_cancel_button; + public String title; + public long initially_selected_mii_index; + // List of Miis to display + public String[] mii_names; + } + + public static class MiiSelectorData { + public long return_code; + public int index; + + private MiiSelectorData(long return_code, int index) { + this.return_code = return_code; + this.index = index; + } + } + + public static class MiiSelectorDialogFragment extends DialogFragment { + static MiiSelectorDialogFragment newInstance(MiiSelectorConfig config) { + MiiSelectorDialogFragment frag = new MiiSelectorDialogFragment(); + Bundle args = new Bundle(); + args.putSerializable("config", config); + frag.setArguments(args); + return frag; + } + + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + final Activity emulationActivity = Objects.requireNonNull(getActivity()); + + MiiSelectorConfig config = + Objects.requireNonNull((MiiSelectorConfig) Objects.requireNonNull(getArguments()) + .getSerializable("config")); + + // Note: we intentionally leave out the Standard Mii in the native code so that + // the string can get translated + ArrayList list = new ArrayList<>(); + list.add(emulationActivity.getString(R.string.standard_mii)); + list.addAll(Arrays.asList(config.mii_names)); + + final int initialIndex = config.initially_selected_mii_index < list.size() + ? (int) config.initially_selected_mii_index + : 0; + data.index = initialIndex; + AlertDialog.Builder builder = + new AlertDialog.Builder(emulationActivity) + .setTitle(config.title.isEmpty() + ? emulationActivity.getString(R.string.mii_selector) + : config.title) + .setSingleChoiceItems(list.toArray(new String[]{}), initialIndex, + (dialog, which) -> { + data.index = which; + }) + .setPositiveButton(android.R.string.ok, (dialog, which) -> { + data.return_code = 0; + synchronized (finishLock) { + finishLock.notifyAll(); + } + }); + if (config.enable_cancel_button) { + builder.setNegativeButton(android.R.string.cancel, (dialog, which) -> { + data.return_code = 1; + synchronized (finishLock) { + finishLock.notifyAll(); + } + }); + } + setCancelable(false); + return builder.create(); + } + } + + private static MiiSelectorData data; + private static final Object finishLock = new Object(); + + private static void ExecuteImpl(MiiSelectorConfig config) { + final EmulationActivity emulationActivity = NativeLibrary.sEmulationActivity.get(); + + data = new MiiSelectorData(0, 0); + + MiiSelectorDialogFragment fragment = MiiSelectorDialogFragment.newInstance(config); + fragment.show(emulationActivity.getSupportFragmentManager(), "mii_selector"); + } + + public static MiiSelectorData Execute(MiiSelectorConfig config) { + NativeLibrary.sEmulationActivity.get().runOnUiThread(() -> ExecuteImpl(config)); + + synchronized (finishLock) { + try { + finishLock.wait(); + } catch (Exception ignored) { + } + } + + return data; + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/applets/SoftwareKeyboard.java b/src/android/app/src/main/java/org/citra/citra_emu/applets/SoftwareKeyboard.java new file mode 100644 index 000000000..7be5f6d97 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/applets/SoftwareKeyboard.java @@ -0,0 +1,264 @@ +// Copyright 2020 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.applets; + +import android.app.Activity; +import android.app.Dialog; +import android.content.DialogInterface; +import android.os.Bundle; +import android.text.InputFilter; +import android.text.Spanned; +import android.view.ViewGroup; +import android.widget.EditText; +import android.widget.FrameLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.DialogFragment; + +import org.citra.citra_emu.CitraApplication; +import org.citra.citra_emu.NativeLibrary; +import org.citra.citra_emu.R; +import org.citra.citra_emu.activities.EmulationActivity; +import org.citra.citra_emu.utils.Log; + +import java.util.Objects; + +public final class SoftwareKeyboard { + /// Corresponds to Frontend::ButtonConfig + private interface ButtonConfig { + int Single = 0; /// Ok button + int Dual = 1; /// Cancel | Ok buttons + int Triple = 2; /// Cancel | I Forgot | Ok buttons + int None = 3; /// No button (returned by swkbdInputText in special cases) + } + + /// Corresponds to Frontend::ValidationError + public enum ValidationError { + None, + // Button Selection + ButtonOutOfRange, + // Configured Filters + MaxDigitsExceeded, + AtSignNotAllowed, + PercentNotAllowed, + BackslashNotAllowed, + ProfanityNotAllowed, + CallbackFailed, + // Allowed Input Type + FixedLengthRequired, + MaxLengthExceeded, + BlankInputNotAllowed, + EmptyInputNotAllowed, + } + + public static class KeyboardConfig implements java.io.Serializable { + public int button_config; + public int max_text_length; + public boolean multiline_mode; /// True if the keyboard accepts multiple lines of input + public String hint_text; /// Displayed in the field as a hint before + @Nullable + public String[] button_text; /// Contains the button text that the caller provides + } + + /// Corresponds to Frontend::KeyboardData + public static class KeyboardData { + public int button; + public String text; + + private KeyboardData(int button, String text) { + this.button = button; + this.text = text; + } + } + + private static class Filter implements InputFilter { + @Override + public CharSequence filter(CharSequence source, int start, int end, Spanned dest, + int dstart, int dend) { + String text = new StringBuilder(dest) + .replace(dstart, dend, source.subSequence(start, end).toString()) + .toString(); + if (ValidateFilters(text) == ValidationError.None) { + return null; // Accept replacement + } + return dest.subSequence(dstart, dend); // Request the subsequence to be unchanged + } + } + + public static class KeyboardDialogFragment extends DialogFragment { + static KeyboardDialogFragment newInstance(KeyboardConfig config) { + KeyboardDialogFragment frag = new KeyboardDialogFragment(); + Bundle args = new Bundle(); + args.putSerializable("config", config); + frag.setArguments(args); + return frag; + } + + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + final Activity emulationActivity = getActivity(); + assert emulationActivity != null; + + FrameLayout.LayoutParams params = new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); + params.leftMargin = params.rightMargin = + CitraApplication.getAppContext().getResources().getDimensionPixelSize( + R.dimen.dialog_margin); + + KeyboardConfig config = Objects.requireNonNull( + (KeyboardConfig) Objects.requireNonNull(getArguments()).getSerializable("config")); + + // Set up the input + EditText editText = new EditText(CitraApplication.getAppContext()); + editText.setHint(config.hint_text); + editText.setSingleLine(!config.multiline_mode); + editText.setLayoutParams(params); + editText.setFilters(new InputFilter[]{ + new Filter(), new InputFilter.LengthFilter(config.max_text_length)}); + + FrameLayout container = new FrameLayout(emulationActivity); + container.addView(editText); + + AlertDialog.Builder builder = new AlertDialog.Builder(emulationActivity) + .setTitle(R.string.software_keyboard) + .setView(container); + setCancelable(false); + + switch (config.button_config) { + case ButtonConfig.Triple: { + final String text = config.button_text[1].isEmpty() + ? emulationActivity.getString(R.string.i_forgot) + : config.button_text[1]; + builder.setNeutralButton(text, null); + } + // fallthrough + case ButtonConfig.Dual: { + final String text = config.button_text[0].isEmpty() + ? emulationActivity.getString(android.R.string.cancel) + : config.button_text[0]; + builder.setNegativeButton(text, null); + } + // fallthrough + case ButtonConfig.Single: { + final String text = config.button_text[2].isEmpty() + ? emulationActivity.getString(android.R.string.ok) + : config.button_text[2]; + builder.setPositiveButton(text, null); + break; + } + } + + final AlertDialog dialog = builder.create(); + dialog.create(); + if (dialog.getButton(DialogInterface.BUTTON_POSITIVE) != null) { + dialog.getButton(DialogInterface.BUTTON_POSITIVE).setOnClickListener((view) -> { + data.button = config.button_config; + data.text = editText.getText().toString(); + final ValidationError error = ValidateInput(data.text); + if (error != ValidationError.None) { + HandleValidationError(config, error); + return; + } + + dialog.dismiss(); + + synchronized (finishLock) { + finishLock.notifyAll(); + } + }); + } + if (dialog.getButton(DialogInterface.BUTTON_NEUTRAL) != null) { + dialog.getButton(DialogInterface.BUTTON_NEUTRAL).setOnClickListener((view) -> { + data.button = 1; + dialog.dismiss(); + synchronized (finishLock) { + finishLock.notifyAll(); + } + }); + } + if (dialog.getButton(DialogInterface.BUTTON_NEGATIVE) != null) { + dialog.getButton(DialogInterface.BUTTON_NEGATIVE).setOnClickListener((view) -> { + data.button = 0; + dialog.dismiss(); + synchronized (finishLock) { + finishLock.notifyAll(); + } + }); + } + + return dialog; + } + } + + private static KeyboardData data; + private static final Object finishLock = new Object(); + + private static void ExecuteImpl(KeyboardConfig config) { + final EmulationActivity emulationActivity = NativeLibrary.sEmulationActivity.get(); + + data = new KeyboardData(0, ""); + + KeyboardDialogFragment fragment = KeyboardDialogFragment.newInstance(config); + fragment.show(emulationActivity.getSupportFragmentManager(), "keyboard"); + } + + private static void HandleValidationError(KeyboardConfig config, ValidationError error) { + final EmulationActivity emulationActivity = NativeLibrary.sEmulationActivity.get(); + String message = ""; + switch (error) { + case FixedLengthRequired: + message = + emulationActivity.getString(R.string.fixed_length_required, config.max_text_length); + break; + case MaxLengthExceeded: + message = + emulationActivity.getString(R.string.max_length_exceeded, config.max_text_length); + break; + case BlankInputNotAllowed: + message = emulationActivity.getString(R.string.blank_input_not_allowed); + break; + case EmptyInputNotAllowed: + message = emulationActivity.getString(R.string.empty_input_not_allowed); + break; + } + + new AlertDialog.Builder(emulationActivity) + .setTitle(R.string.software_keyboard) + .setMessage(message) + .setPositiveButton(android.R.string.ok, null) + .show(); + } + + public static KeyboardData Execute(KeyboardConfig config) { + if (config.button_config == ButtonConfig.None) { + Log.error("Unexpected button config None"); + return new KeyboardData(0, ""); + } + + NativeLibrary.sEmulationActivity.get().runOnUiThread(() -> ExecuteImpl(config)); + + synchronized (finishLock) { + try { + finishLock.wait(); + } catch (Exception ignored) { + } + } + + return data; + } + + public static void ShowError(String error) { + NativeLibrary.displayAlertMsg( + CitraApplication.getAppContext().getResources().getString(R.string.software_keyboard), + error, false); + } + + private static native ValidationError ValidateFilters(String text); + + private static native ValidationError ValidateInput(String text); +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/camera/StillImageCameraHelper.java b/src/android/app/src/main/java/org/citra/citra_emu/camera/StillImageCameraHelper.java new file mode 100644 index 000000000..701cb0710 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/camera/StillImageCameraHelper.java @@ -0,0 +1,65 @@ +// Copyright 2020 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.camera; + +import android.content.Intent; +import android.graphics.Bitmap; +import android.provider.MediaStore; + +import org.citra.citra_emu.NativeLibrary; +import org.citra.citra_emu.R; +import org.citra.citra_emu.activities.EmulationActivity; +import org.citra.citra_emu.utils.PicassoUtils; + +import androidx.annotation.Nullable; + +// Used in native code. +public final class StillImageCameraHelper { + public static final int REQUEST_CAMERA_FILE_PICKER = 1; + private static final Object filePickerLock = new Object(); + private static @Nullable + String filePickerPath; + + // Opens file picker for camera. + public static @Nullable + String OpenFilePicker() { + final EmulationActivity emulationActivity = NativeLibrary.sEmulationActivity.get(); + + // At this point, we are assuming that we already have permissions as they are + // needed to launch a game + emulationActivity.runOnUiThread(() -> { + Intent intent = new Intent(Intent.ACTION_PICK); + intent.setDataAndType(MediaStore.Images.Media.INTERNAL_CONTENT_URI, "image/*"); + emulationActivity.startActivityForResult( + Intent.createChooser(intent, + emulationActivity.getString(R.string.camera_select_image)), + REQUEST_CAMERA_FILE_PICKER); + }); + + synchronized (filePickerLock) { + try { + filePickerLock.wait(); + } catch (InterruptedException ignored) { + } + } + + return filePickerPath; + } + + // Called from EmulationActivity. + public static void OnFilePickerResult(Intent result) { + filePickerPath = result == null ? null : result.getDataString(); + + synchronized (filePickerLock) { + filePickerLock.notifyAll(); + } + } + + // Blocking call. Load image from file and crop/resize it to fit in width x height. + @Nullable + public static Bitmap LoadImageFromFile(String uri, int width, int height) { + return PicassoUtils.LoadBitmapFromFile(uri, width, height); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/dialogs/MotionAlertDialog.java b/src/android/app/src/main/java/org/citra/citra_emu/dialogs/MotionAlertDialog.java new file mode 100644 index 000000000..0f10f1858 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/dialogs/MotionAlertDialog.java @@ -0,0 +1,140 @@ +package org.citra.citra_emu.dialogs; + +import android.content.Context; +import android.view.InputDevice; +import android.view.KeyEvent; +import android.view.MotionEvent; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; + +import org.citra.citra_emu.features.settings.model.view.InputBindingSetting; +import org.citra.citra_emu.utils.Log; + +import java.util.ArrayList; +import java.util.List; + +/** + * {@link AlertDialog} derivative that listens for + * motion events from controllers and joysticks. + */ +public final class MotionAlertDialog extends AlertDialog { + // The selected input preference + private final InputBindingSetting setting; + private final ArrayList mPreviousValues = new ArrayList<>(); + private int mPrevDeviceId = 0; + private boolean mWaitingForEvent = true; + + /** + * Constructor + * + * @param context The current {@link Context}. + * @param setting The Preference to show this dialog for. + */ + public MotionAlertDialog(Context context, InputBindingSetting setting) { + super(context); + + this.setting = setting; + } + + public boolean onKeyEvent(int keyCode, KeyEvent event) { + Log.debug("[MotionAlertDialog] Received key event: " + event.getAction()); + switch (event.getAction()) { + case KeyEvent.ACTION_UP: + setting.onKeyInput(event); + dismiss(); + // Even if we ignore the key, we still consume it. Thus return true regardless. + return true; + + default: + return false; + } + } + + @Override + public boolean onKeyLongPress(int keyCode, @NonNull KeyEvent event) { + return super.onKeyLongPress(keyCode, event); + } + + @Override + public boolean dispatchKeyEvent(KeyEvent event) { + // Handle this key if we care about it, otherwise pass it down the framework + return onKeyEvent(event.getKeyCode(), event) || super.dispatchKeyEvent(event); + } + + @Override + public boolean dispatchGenericMotionEvent(@NonNull MotionEvent event) { + // Handle this event if we care about it, otherwise pass it down the framework + return onMotionEvent(event) || super.dispatchGenericMotionEvent(event); + } + + private boolean onMotionEvent(MotionEvent event) { + if ((event.getSource() & InputDevice.SOURCE_CLASS_JOYSTICK) == 0) + return false; + if (event.getAction() != MotionEvent.ACTION_MOVE) + return false; + + InputDevice input = event.getDevice(); + + List motionRanges = input.getMotionRanges(); + + if (input.getId() != mPrevDeviceId) { + mPreviousValues.clear(); + } + mPrevDeviceId = input.getId(); + boolean firstEvent = mPreviousValues.isEmpty(); + + int numMovedAxis = 0; + float axisMoveValue = 0.0f; + InputDevice.MotionRange lastMovedRange = null; + char lastMovedDir = '?'; + if (mWaitingForEvent) { + for (int i = 0; i < motionRanges.size(); i++) { + InputDevice.MotionRange range = motionRanges.get(i); + int axis = range.getAxis(); + float origValue = event.getAxisValue(axis); + float value = origValue;//ControllerMappingHelper.scaleAxis(input, axis, origValue); + if (firstEvent) { + mPreviousValues.add(value); + } else { + float previousValue = mPreviousValues.get(i); + + // Only handle the axes that are not neutral (more than 0.5) + // but ignore any axis that has a constant value (e.g. always 1) + if (Math.abs(value) > 0.5f && value != previousValue) { + // It is common to have multiple axes with the same physical input. For example, + // shoulder butters are provided as both AXIS_LTRIGGER and AXIS_BRAKE. + // To handle this, we ignore an axis motion that's the exact same as a motion + // we already saw. This way, we ignore axes with two names, but catch the case + // where a joystick is moved in two directions. + // ref: bottom of https://developer.android.com/training/game-controllers/controller-input.html + if (value != axisMoveValue) { + axisMoveValue = value; + numMovedAxis++; + lastMovedRange = range; + lastMovedDir = value < 0.0f ? '-' : '+'; + } + } + // Special case for d-pads (axis value jumps between 0 and 1 without any values + // in between). Without this, the user would need to press the d-pad twice + // due to the first press being caught by the "if (firstEvent)" case further up. + else if (Math.abs(value) < 0.25f && Math.abs(previousValue) > 0.75f) { + numMovedAxis++; + lastMovedRange = range; + lastMovedDir = previousValue < 0.0f ? '-' : '+'; + } + } + + mPreviousValues.set(i, value); + } + + // If only one axis moved, that's the winner. + if (numMovedAxis == 1) { + mWaitingForEvent = false; + setting.onMotionInput(input, lastMovedRange, lastMovedDir); + dismiss(); + } + } + return true; + } +} \ No newline at end of file diff --git a/src/android/app/src/main/java/org/citra/citra_emu/disk_shader_cache/DiskShaderCacheProgress.java b/src/android/app/src/main/java/org/citra/citra_emu/disk_shader_cache/DiskShaderCacheProgress.java new file mode 100644 index 000000000..8f9d215a3 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/disk_shader_cache/DiskShaderCacheProgress.java @@ -0,0 +1,139 @@ +// Copyright 2021 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.disk_shader_cache; + +import android.app.Activity; +import android.app.Dialog; +import android.content.DialogInterface; +import android.os.Bundle; +import android.os.Handler; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.ProgressBar; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.DialogFragment; + +import org.citra.citra_emu.NativeLibrary; +import org.citra.citra_emu.R; +import org.citra.citra_emu.activities.EmulationActivity; +import org.citra.citra_emu.utils.Log; + +import java.util.Objects; + +public class DiskShaderCacheProgress { + + // Equivalent to VideoCore::LoadCallbackStage + public enum LoadCallbackStage { + Prepare, + Decompile, + Build, + Complete, + } + + private static final Object finishLock = new Object(); + private static ProgressDialogFragment fragment; + + public static class ProgressDialogFragment extends DialogFragment { + ProgressBar progressBar; + TextView progressText; + AlertDialog dialog; + + static ProgressDialogFragment newInstance(String title, String message) { + ProgressDialogFragment frag = new ProgressDialogFragment(); + Bundle args = new Bundle(); + args.putString("title", title); + args.putString("message", message); + frag.setArguments(args); + return frag; + } + + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + final Activity emulationActivity = Objects.requireNonNull(getActivity()); + + final String title = Objects.requireNonNull(Objects.requireNonNull(getArguments()).getString("title")); + final String message = Objects.requireNonNull(Objects.requireNonNull(getArguments()).getString("message")); + + LayoutInflater inflater = LayoutInflater.from(emulationActivity); + View view = inflater.inflate(R.layout.dialog_progress_bar, null); + + progressBar = view.findViewById(R.id.progress_bar); + progressText = view.findViewById(R.id.progress_text); + progressText.setText(""); + + setCancelable(false); + setRetainInstance(true); + + AlertDialog.Builder builder = new AlertDialog.Builder(emulationActivity); + builder.setTitle(title); + builder.setMessage(message); + builder.setView(view); + builder.setNegativeButton(android.R.string.cancel, null); + + dialog = builder.create(); + dialog.create(); + + dialog.getButton(DialogInterface.BUTTON_NEGATIVE).setOnClickListener((v) -> emulationActivity.onBackPressed()); + + synchronized (finishLock) { + finishLock.notifyAll(); + } + + return dialog; + } + + private void onUpdateProgress(String msg, int progress, int max) { + Objects.requireNonNull(getActivity()).runOnUiThread(() -> { + progressBar.setProgress(progress); + progressBar.setMax(max); + progressText.setText(String.format("%d/%d", progress, max)); + dialog.setMessage(msg); + }); + } + } + + private static void prepareDialog() { + NativeLibrary.sEmulationActivity.get().runOnUiThread(() -> { + final EmulationActivity emulationActivity = NativeLibrary.sEmulationActivity.get(); + fragment = ProgressDialogFragment.newInstance(emulationActivity.getString(R.string.loading), emulationActivity.getString(R.string.preparing_shaders)); + fragment.show(emulationActivity.getSupportFragmentManager(), "diskShaders"); + }); + + synchronized (finishLock) { + try { + finishLock.wait(); + } catch (Exception ignored) { + } + } + } + + public static void loadProgress(LoadCallbackStage stage, int progress, int max) { + final EmulationActivity emulationActivity = NativeLibrary.sEmulationActivity.get(); + if (emulationActivity == null) { + Log.error("[DiskShaderCacheProgress] EmulationActivity not present"); + return; + } + + switch (stage) { + case Prepare: + prepareDialog(); + break; + case Decompile: + fragment.onUpdateProgress(emulationActivity.getString(R.string.preparing_shaders), progress, max); + break; + case Build: + fragment.onUpdateProgress(emulationActivity.getString(R.string.building_shaders), progress, max); + break; + case Complete: + // Workaround for when dialog is dismissed when the app is in the background + fragment.dismissAllowingStateLoss(); + break; + } + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/BooleanSetting.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/BooleanSetting.java new file mode 100644 index 000000000..932dcf1d3 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/BooleanSetting.java @@ -0,0 +1,23 @@ +package org.citra.citra_emu.features.settings.model; + +public final class BooleanSetting extends Setting { + private boolean mValue; + + public BooleanSetting(String key, String section, boolean value) { + super(key, section); + mValue = value; + } + + public boolean getValue() { + return mValue; + } + + public void setValue(boolean value) { + mValue = value; + } + + @Override + public String getValueAsString() { + return mValue ? "True" : "False"; + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/FloatSetting.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/FloatSetting.java new file mode 100644 index 000000000..275f0ecea --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/FloatSetting.java @@ -0,0 +1,23 @@ +package org.citra.citra_emu.features.settings.model; + +public final class FloatSetting extends Setting { + private float mValue; + + public FloatSetting(String key, String section, float value) { + super(key, section); + mValue = value; + } + + public float getValue() { + return mValue; + } + + public void setValue(float value) { + mValue = value; + } + + @Override + public String getValueAsString() { + return Float.toString(mValue); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/IntSetting.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/IntSetting.java new file mode 100644 index 000000000..f712e5bfa --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/IntSetting.java @@ -0,0 +1,23 @@ +package org.citra.citra_emu.features.settings.model; + +public final class IntSetting extends Setting { + private int mValue; + + public IntSetting(String key, String section, int value) { + super(key, section); + mValue = value; + } + + public int getValue() { + return mValue; + } + + public void setValue(int value) { + mValue = value; + } + + @Override + public String getValueAsString() { + return Integer.toString(mValue); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/Setting.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/Setting.java new file mode 100644 index 000000000..b762847c9 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/Setting.java @@ -0,0 +1,42 @@ +package org.citra.citra_emu.features.settings.model; + +/** + * Abstraction for a setting item as read from / written to Citra's configuration ini files. + * These files generally consist of a key/value pair, though the type of value is ambiguous and + * must be inferred at read-time. The type of value determines which child of this class is used + * to represent the Setting. + */ +public abstract class Setting { + private String mKey; + private String mSection; + + /** + * Base constructor. + * + * @param key Everything to the left of the = in a line from the ini file. + * @param section The corresponding recent section header; e.g. [Core] or [Enhancements] without the brackets. + */ + public Setting(String key, String section) { + mKey = key; + mSection = section; + } + + /** + * @return The identifier used to write this setting to the ini file. + */ + public String getKey() { + return mKey; + } + + /** + * @return The name of the header under which this Setting should be written in the ini file. + */ + public String getSection() { + return mSection; + } + + /** + * @return A representation of this Setting's backing value converted to a String (e.g. for serialization). + */ + public abstract String getValueAsString(); +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/SettingSection.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/SettingSection.java new file mode 100644 index 000000000..0a291aa6b --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/SettingSection.java @@ -0,0 +1,55 @@ +package org.citra.citra_emu.features.settings.model; + +import java.util.HashMap; + +/** + * A semantically-related group of Settings objects. These Settings are + * internally stored as a HashMap. + */ +public final class SettingSection { + private String mName; + + private HashMap mSettings = new HashMap<>(); + + /** + * Create a new SettingSection with no Settings in it. + * + * @param name The header of this section; e.g. [Core] or [Enhancements] without the brackets. + */ + public SettingSection(String name) { + mName = name; + } + + public String getName() { + return mName; + } + + /** + * Convenience method; inserts a value directly into the backing HashMap. + * + * @param setting The Setting to be inserted. + */ + public void putSetting(Setting setting) { + mSettings.put(setting.getKey(), 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) + */ + public Setting getSetting(String key) { + return mSettings.get(key); + } + + public HashMap getSettings() { + return mSettings; + } + + public void mergeSection(SettingSection settingSection) { + for (Setting setting : settingSection.mSettings.values()) { + putSetting(setting); + } + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/Settings.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/Settings.java new file mode 100644 index 000000000..dc6778409 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/Settings.java @@ -0,0 +1,131 @@ +package org.citra.citra_emu.features.settings.model; + +import android.text.TextUtils; + +import org.citra.citra_emu.CitraApplication; +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.settings.ui.SettingsActivityView; +import org.citra.citra_emu.features.settings.utils.SettingsFile; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +public class Settings { + public static final String SECTION_PREMIUM = "Premium"; + public static final String SECTION_CORE = "Core"; + public static final String SECTION_SYSTEM = "System"; + public static final String SECTION_CAMERA = "Camera"; + public static final String SECTION_CONTROLS = "Controls"; + public static final String SECTION_RENDERER = "Renderer"; + public static final String SECTION_LAYOUT = "Layout"; + public static final String SECTION_AUDIO = "Audio"; + public static final String SECTION_DEBUG = "Debug"; + + private String gameId; + + private static final Map> configFileSectionsMap = new HashMap<>(); + + static { + configFileSectionsMap.put(SettingsFile.FILE_NAME_CONFIG, Arrays.asList(SECTION_PREMIUM, SECTION_CORE, SECTION_SYSTEM, SECTION_CAMERA, SECTION_CONTROLS, SECTION_RENDERER, SECTION_LAYOUT, SECTION_AUDIO, SECTION_DEBUG)); + } + + /** + * A HashMap that constructs a new SettingSection instead of returning null + * when getting a key not already in the map + */ + public static final class SettingsSectionMap extends HashMap { + @Override + public SettingSection get(Object key) { + if (!(key instanceof String)) { + return null; + } + + String stringKey = (String) key; + + if (!super.containsKey(stringKey)) { + SettingSection section = new SettingSection(stringKey); + super.put(stringKey, section); + return section; + } + return super.get(key); + } + } + + private HashMap sections = new Settings.SettingsSectionMap(); + + public SettingSection getSection(String sectionName) { + return sections.get(sectionName); + } + + public boolean isEmpty() { + return sections.isEmpty(); + } + + public HashMap getSections() { + return sections; + } + + public void loadSettings(SettingsActivityView view) { + sections = new Settings.SettingsSectionMap(); + loadCitraSettings(view); + + if (!TextUtils.isEmpty(gameId)) { + loadCustomGameSettings(gameId, view); + } + } + + private void loadCitraSettings(SettingsActivityView view) { + for (Map.Entry> entry : configFileSectionsMap.entrySet()) { + String fileName = entry.getKey(); + sections.putAll(SettingsFile.readFile(fileName, view)); + } + } + + private void loadCustomGameSettings(String gameId, SettingsActivityView view) { + // custom game settings + mergeSections(SettingsFile.readCustomGameSettings(gameId, view)); + } + + private void mergeSections(HashMap updatedSections) { + for (Map.Entry entry : updatedSections.entrySet()) { + if (sections.containsKey(entry.getKey())) { + SettingSection originalSection = sections.get(entry.getKey()); + SettingSection updatedSection = entry.getValue(); + originalSection.mergeSection(updatedSection); + } else { + sections.put(entry.getKey(), entry.getValue()); + } + } + } + + public void loadSettings(String gameId, SettingsActivityView view) { + this.gameId = gameId; + loadSettings(view); + } + + public void saveSettings(SettingsActivityView view) { + if (TextUtils.isEmpty(gameId)) { + view.showToastMessage(CitraApplication.getAppContext().getString(R.string.ini_saved), false); + + for (Map.Entry> entry : configFileSectionsMap.entrySet()) { + String fileName = entry.getKey(); + List sectionNames = entry.getValue(); + TreeMap iniSections = new TreeMap<>(); + for (String section : sectionNames) { + iniSections.put(section, sections.get(section)); + } + + SettingsFile.saveFile(fileName, iniSections, view); + } + } else { + // custom game settings + view.showToastMessage(CitraApplication.getAppContext().getString(R.string.gameid_saved, gameId), false); + + SettingsFile.saveCustomGameSettings(gameId, sections); + } + + } +} \ No newline at end of file diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/StringSetting.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/StringSetting.java new file mode 100644 index 000000000..b906b7010 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/StringSetting.java @@ -0,0 +1,23 @@ +package org.citra.citra_emu.features.settings.model; + +public final class StringSetting extends Setting { + private String mValue; + + public StringSetting(String key, String section, String value) { + super(key, section); + mValue = value; + } + + public String getValue() { + return mValue; + } + + public void setValue(String value) { + mValue = value; + } + + @Override + public String getValueAsString() { + return mValue; + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/CheckBoxSetting.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/CheckBoxSetting.java new file mode 100644 index 000000000..baf40709f --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/CheckBoxSetting.java @@ -0,0 +1,80 @@ +package org.citra.citra_emu.features.settings.model.view; + +import org.citra.citra_emu.CitraApplication; +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.settings.model.BooleanSetting; +import org.citra.citra_emu.features.settings.model.IntSetting; +import org.citra.citra_emu.features.settings.model.Setting; +import org.citra.citra_emu.features.settings.ui.SettingsFragmentView; + +public final class CheckBoxSetting extends SettingsItem { + private boolean mDefaultValue; + private boolean mShowPerformanceWarning; + private SettingsFragmentView mView; + + public CheckBoxSetting(String key, String section, int titleId, int descriptionId, + boolean defaultValue, Setting setting) { + super(key, section, setting, titleId, descriptionId); + mDefaultValue = defaultValue; + mShowPerformanceWarning = false; + } + + public CheckBoxSetting(String key, String section, int titleId, int descriptionId, + boolean defaultValue, Setting setting, boolean show_performance_warning, SettingsFragmentView view) { + super(key, section, setting, titleId, descriptionId); + mDefaultValue = defaultValue; + mView = view; + mShowPerformanceWarning = show_performance_warning; + } + + public boolean isChecked() { + if (getSetting() == null) { + return mDefaultValue; + } + + // Try integer setting + try { + IntSetting setting = (IntSetting) getSetting(); + return setting.getValue() == 1; + } catch (ClassCastException exception) { + } + + // Try boolean setting + try { + BooleanSetting setting = (BooleanSetting) getSetting(); + return setting.getValue() == true; + } catch (ClassCastException exception) { + } + + return mDefaultValue; + } + + /** + * 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 null if overwritten successfully; otherwise, a newly created BooleanSetting. + */ + public IntSetting setChecked(boolean checked) { + // Show a performance warning if the setting has been disabled + if (mShowPerformanceWarning && !checked) { + mView.showToastMessage(CitraApplication.getAppContext().getString(R.string.performance_warning), true); + } + + if (getSetting() == null) { + IntSetting setting = new IntSetting(getKey(), getSection(), checked ? 1 : 0); + setSetting(setting); + return setting; + } else { + IntSetting setting = (IntSetting) getSetting(); + setting.setValue(checked ? 1 : 0); + return null; + } + } + + @Override + public int getType() { + return TYPE_CHECKBOX; + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/DateTimeSetting.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/DateTimeSetting.java new file mode 100644 index 000000000..afc3352cc --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/DateTimeSetting.java @@ -0,0 +1,40 @@ +package org.citra.citra_emu.features.settings.model.view; + +import org.citra.citra_emu.features.settings.model.Setting; +import org.citra.citra_emu.features.settings.model.StringSetting; + +public final class DateTimeSetting extends SettingsItem { + private String mDefaultValue; + + public DateTimeSetting(String key, String section, int titleId, int descriptionId, + String defaultValue, Setting setting) { + super(key, section, setting, titleId, descriptionId); + mDefaultValue = defaultValue; + } + + public String getValue() { + if (getSetting() != null) { + StringSetting setting = (StringSetting) getSetting(); + return setting.getValue(); + } else { + return mDefaultValue; + } + } + + public StringSetting setSelectedValue(String datetime) { + if (getSetting() == null) { + StringSetting setting = new StringSetting(getKey(), getSection(), datetime); + setSetting(setting); + return setting; + } else { + StringSetting setting = (StringSetting) getSetting(); + setting.setValue(datetime); + return null; + } + } + + @Override + public int getType() { + return TYPE_DATETIME_SETTING; + } +} \ No newline at end of file diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/HeaderSetting.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/HeaderSetting.java new file mode 100644 index 000000000..bac8876cd --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/HeaderSetting.java @@ -0,0 +1,14 @@ +package org.citra.citra_emu.features.settings.model.view; + +import org.citra.citra_emu.features.settings.model.Setting; + +public final class HeaderSetting extends SettingsItem { + public HeaderSetting(String key, Setting setting, int titleId, int descriptionId) { + super(key, null, setting, titleId, descriptionId); + } + + @Override + public int getType() { + return SettingsItem.TYPE_HEADER; + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/InputBindingSetting.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/InputBindingSetting.java new file mode 100644 index 000000000..e9141a208 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/InputBindingSetting.java @@ -0,0 +1,382 @@ +package org.citra.citra_emu.features.settings.model.view; + +import android.content.SharedPreferences; +import android.preference.PreferenceManager; +import android.view.InputDevice; +import android.view.KeyEvent; +import android.widget.Toast; + +import org.citra.citra_emu.CitraApplication; +import org.citra.citra_emu.NativeLibrary; +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.settings.model.Setting; +import org.citra.citra_emu.features.settings.model.StringSetting; +import org.citra.citra_emu.features.settings.utils.SettingsFile; + +public final class InputBindingSetting extends SettingsItem { + private static final String INPUT_MAPPING_PREFIX = "InputMapping"; + + public InputBindingSetting(String key, String section, int titleId, Setting setting) { + super(key, section, setting, titleId, 0); + } + + public String getValue() { + if (getSetting() == null) { + return ""; + } + + StringSetting setting = (StringSetting) getSetting(); + return setting.getValue(); + } + + /** + * Returns true if this key is for the 3DS Circle Pad + */ + private boolean IsCirclePad() { + switch (getKey()) { + case SettingsFile.KEY_CIRCLEPAD_AXIS_HORIZONTAL: + case SettingsFile.KEY_CIRCLEPAD_AXIS_VERTICAL: + return true; + } + return false; + } + + /** + * Returns true if this key is for a horizontal axis for a 3DS analog stick or D-pad + */ + public boolean IsHorizontalOrientation() { + switch (getKey()) { + case SettingsFile.KEY_CIRCLEPAD_AXIS_HORIZONTAL: + case SettingsFile.KEY_CSTICK_AXIS_HORIZONTAL: + case SettingsFile.KEY_DPAD_AXIS_HORIZONTAL: + return true; + } + return false; + } + + /** + * Returns true if this key is for the 3DS C-Stick + */ + private boolean IsCStick() { + switch (getKey()) { + case SettingsFile.KEY_CSTICK_AXIS_HORIZONTAL: + case SettingsFile.KEY_CSTICK_AXIS_VERTICAL: + return true; + } + return false; + } + + /** + * Returns true if this key is for the 3DS D-Pad + */ + private boolean IsDPad() { + switch (getKey()) { + case SettingsFile.KEY_DPAD_AXIS_HORIZONTAL: + case SettingsFile.KEY_DPAD_AXIS_VERTICAL: + return true; + } + return false; + } + + /** + * Returns true if this key is for the 3DS L/R or ZL/ZR buttons. Note, these are not real + * triggers on the 3DS, but we support them as such on a physical gamepad. + */ + public boolean IsTrigger() { + switch (getKey()) { + case SettingsFile.KEY_BUTTON_L: + case SettingsFile.KEY_BUTTON_R: + case SettingsFile.KEY_BUTTON_ZL: + case SettingsFile.KEY_BUTTON_ZR: + return true; + } + return false; + } + + /** + * Returns true if a gamepad axis can be used to map this key. + */ + public boolean IsAxisMappingSupported() { + return IsCirclePad() || IsCStick() || IsDPad() || IsTrigger(); + } + + /** + * Returns true if a gamepad button can be used to map this key. + */ + private boolean IsButtonMappingSupported() { + return !IsAxisMappingSupported() || IsTrigger(); + } + + /** + * Returns the Citra button code for the settings key. + */ + private int getButtonCode() { + switch (getKey()) { + case SettingsFile.KEY_BUTTON_A: + return NativeLibrary.ButtonType.BUTTON_A; + case SettingsFile.KEY_BUTTON_B: + return NativeLibrary.ButtonType.BUTTON_B; + case SettingsFile.KEY_BUTTON_X: + return NativeLibrary.ButtonType.BUTTON_X; + case SettingsFile.KEY_BUTTON_Y: + return NativeLibrary.ButtonType.BUTTON_Y; + case SettingsFile.KEY_BUTTON_L: + return NativeLibrary.ButtonType.TRIGGER_L; + case SettingsFile.KEY_BUTTON_R: + return NativeLibrary.ButtonType.TRIGGER_R; + case SettingsFile.KEY_BUTTON_ZL: + return NativeLibrary.ButtonType.BUTTON_ZL; + case SettingsFile.KEY_BUTTON_ZR: + return NativeLibrary.ButtonType.BUTTON_ZR; + case SettingsFile.KEY_BUTTON_SELECT: + return NativeLibrary.ButtonType.BUTTON_SELECT; + case SettingsFile.KEY_BUTTON_START: + return NativeLibrary.ButtonType.BUTTON_START; + case SettingsFile.KEY_BUTTON_UP: + return NativeLibrary.ButtonType.DPAD_UP; + case SettingsFile.KEY_BUTTON_DOWN: + return NativeLibrary.ButtonType.DPAD_DOWN; + case SettingsFile.KEY_BUTTON_LEFT: + return NativeLibrary.ButtonType.DPAD_LEFT; + case SettingsFile.KEY_BUTTON_RIGHT: + return NativeLibrary.ButtonType.DPAD_RIGHT; + } + return -1; + } + + /** + * Returns the settings key for the specified Citra button code. + */ + private static String getButtonKey(int buttonCode) { + switch (buttonCode) { + case NativeLibrary.ButtonType.BUTTON_A: + return SettingsFile.KEY_BUTTON_A; + case NativeLibrary.ButtonType.BUTTON_B: + return SettingsFile.KEY_BUTTON_B; + case NativeLibrary.ButtonType.BUTTON_X: + return SettingsFile.KEY_BUTTON_X; + case NativeLibrary.ButtonType.BUTTON_Y: + return SettingsFile.KEY_BUTTON_Y; + case NativeLibrary.ButtonType.TRIGGER_L: + return SettingsFile.KEY_BUTTON_L; + case NativeLibrary.ButtonType.TRIGGER_R: + return SettingsFile.KEY_BUTTON_R; + case NativeLibrary.ButtonType.BUTTON_ZL: + return SettingsFile.KEY_BUTTON_ZL; + case NativeLibrary.ButtonType.BUTTON_ZR: + return SettingsFile.KEY_BUTTON_ZR; + case NativeLibrary.ButtonType.BUTTON_SELECT: + return SettingsFile.KEY_BUTTON_SELECT; + case NativeLibrary.ButtonType.BUTTON_START: + return SettingsFile.KEY_BUTTON_START; + case NativeLibrary.ButtonType.DPAD_UP: + return SettingsFile.KEY_BUTTON_UP; + case NativeLibrary.ButtonType.DPAD_DOWN: + return SettingsFile.KEY_BUTTON_DOWN; + case NativeLibrary.ButtonType.DPAD_LEFT: + return SettingsFile.KEY_BUTTON_LEFT; + case NativeLibrary.ButtonType.DPAD_RIGHT: + return SettingsFile.KEY_BUTTON_RIGHT; + } + return ""; + } + + /** + * Returns the key used to lookup the reverse mapping for this key, which is used to cleanup old + * settings on re-mapping or clearing of a setting. + */ + private String getReverseKey() { + String reverseKey = INPUT_MAPPING_PREFIX + "_ReverseMapping_" + getKey(); + + if (IsAxisMappingSupported() && !IsTrigger()) { + // Triggers are the only axis-supported mappings without orientation + reverseKey += "_" + (IsHorizontalOrientation() ? 0 : 1); + } + + return reverseKey; + } + + /** + * Removes the old mapping for this key from the settings, e.g. on user clearing the setting. + */ + public void removeOldMapping() { + // Get preferences editor + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext()); + SharedPreferences.Editor editor = preferences.edit(); + + // Try remove all possible keys we wrote for this setting + String oldKey = preferences.getString(getReverseKey(), ""); + if (!oldKey.equals("")) { + editor.remove(getKey()); // Used for ui text + editor.remove(oldKey); // Used for button mapping + editor.remove(oldKey + "_GuestOrientation"); // Used for axis orientation + editor.remove(oldKey + "_GuestButton"); // Used for axis button + } + + // Apply changes + editor.apply(); + } + + /** + * Helper function to get the settings key for an gamepad button. + */ + public static String getInputButtonKey(int keyCode) { + return INPUT_MAPPING_PREFIX + "_Button_" + keyCode; + } + + /** + * Helper function to get the settings key for an gamepad axis. + */ + public static String getInputAxisKey(int axis) { + return INPUT_MAPPING_PREFIX + "_HostAxis_" + axis; + } + + /** + * Helper function to get the settings key for an gamepad axis button (stick or trigger). + */ + public static String getInputAxisButtonKey(int axis) { + return getInputAxisKey(axis) + "_GuestButton"; + } + + /** + * Helper function to get the settings key for an gamepad axis orientation. + */ + public static String getInputAxisOrientationKey(int axis) { + return getInputAxisKey(axis) + "_GuestOrientation"; + } + + /** + * Helper function to write a gamepad button mapping for the setting. + */ + private void WriteButtonMapping(String key) { + // Get preferences editor + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext()); + SharedPreferences.Editor editor = preferences.edit(); + + // Remove mapping for another setting using this input + int oldButtonCode = preferences.getInt(key, -1); + if (oldButtonCode != -1) { + String oldKey = getButtonKey(oldButtonCode); + editor.remove(oldKey); // Only need to remove UI text setting, others will be overwritten + } + + // Cleanup old mapping for this setting + removeOldMapping(); + + // Write new mapping + editor.putInt(key, getButtonCode()); + + // Write next reverse mapping for future cleanup + editor.putString(getReverseKey(), key); + + // Apply changes + editor.apply(); + } + + /** + * Helper function to write a gamepad axis mapping for the setting. + */ + private void WriteAxisMapping(int axis, int value) { + // Get preferences editor + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext()); + SharedPreferences.Editor editor = preferences.edit(); + + // Cleanup old mapping + removeOldMapping(); + + // Write new mapping + editor.putInt(getInputAxisOrientationKey(axis), IsHorizontalOrientation() ? 0 : 1); + editor.putInt(getInputAxisButtonKey(axis), value); + + // Write next reverse mapping for future cleanup + editor.putString(getReverseKey(), getInputAxisKey(axis)); + + // Apply changes + editor.apply(); + } + + /** + * Saves the provided key input setting as an Android preference. + * + * @param keyEvent KeyEvent of this key press. + */ + public void onKeyInput(KeyEvent keyEvent) { + if (!IsButtonMappingSupported()) { + Toast.makeText(CitraApplication.getAppContext(), R.string.input_message_analog_only, Toast.LENGTH_LONG).show(); + return; + } + + InputDevice device = keyEvent.getDevice(); + + WriteButtonMapping(getInputButtonKey(keyEvent.getKeyCode())); + + String uiString = device.getName() + ": Button " + keyEvent.getKeyCode(); + setUiString(uiString); + } + + /** + * Saves the provided motion input setting as an Android preference. + * + * @param device InputDevice from which the input event originated. + * @param motionRange MotionRange of the movement + * @param axisDir Either '-' or '+' (currently unused) + */ + public void onMotionInput(InputDevice device, InputDevice.MotionRange motionRange, + char axisDir) { + if (!IsAxisMappingSupported()) { + Toast.makeText(CitraApplication.getAppContext(), R.string.input_message_button_only, Toast.LENGTH_LONG).show(); + return; + } + + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext()); + SharedPreferences.Editor editor = preferences.edit(); + + int button; + if (IsCirclePad()) { + button = NativeLibrary.ButtonType.STICK_LEFT; + } else if (IsCStick()) { + button = NativeLibrary.ButtonType.STICK_C; + } else if (IsDPad()) { + button = NativeLibrary.ButtonType.DPAD; + } else { + button = getButtonCode(); + } + + WriteAxisMapping(motionRange.getAxis(), button); + + String uiString = device.getName() + ": Axis " + motionRange.getAxis(); + setUiString(uiString); + + editor.apply(); + } + + /** + * Sets the string to use in the configuration UI for the gamepad input. + */ + private StringSetting setUiString(String ui) { + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext()); + SharedPreferences.Editor editor = preferences.edit(); + + if (getSetting() == null) { + StringSetting setting = new StringSetting(getKey(), getSection(), ""); + setSetting(setting); + + editor.putString(setting.getKey(), ui); + editor.apply(); + + return setting; + } else { + StringSetting setting = (StringSetting) getSetting(); + + editor.putString(setting.getKey(), ui); + editor.apply(); + + return null; + } + } + + @Override + public int getType() { + return TYPE_INPUT_BINDING; + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/PremiumHeader.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/PremiumHeader.java new file mode 100644 index 000000000..8942bf13a --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/PremiumHeader.java @@ -0,0 +1,14 @@ +package org.citra.citra_emu.features.settings.model.view; + +import org.citra.citra_emu.features.settings.model.Setting; + +public final class PremiumHeader extends SettingsItem { + public PremiumHeader() { + super(null, null, null, 0, 0); + } + + @Override + public int getType() { + return SettingsItem.TYPE_PREMIUM; + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/PremiumSingleChoiceSetting.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/PremiumSingleChoiceSetting.java new file mode 100644 index 000000000..c0560d2dc --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/PremiumSingleChoiceSetting.java @@ -0,0 +1,59 @@ +package org.citra.citra_emu.features.settings.model.view; + +import android.content.SharedPreferences; +import android.preference.PreferenceManager; + +import org.citra.citra_emu.CitraApplication; +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.settings.model.Setting; +import org.citra.citra_emu.features.settings.ui.SettingsFragmentView; + +public final class PremiumSingleChoiceSetting extends SettingsItem { + private int mDefaultValue; + + private int mChoicesId; + private int mValuesId; + private SettingsFragmentView mView; + + private static SharedPreferences mPreferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext()); + + public PremiumSingleChoiceSetting(String key, String section, int titleId, int descriptionId, + int choicesId, int valuesId, int defaultValue, Setting setting, SettingsFragmentView view) { + super(key, section, setting, titleId, descriptionId); + mValuesId = valuesId; + mChoicesId = choicesId; + mDefaultValue = defaultValue; + mView = view; + } + + public int getChoicesId() { + return mChoicesId; + } + + public int getValuesId() { + return mValuesId; + } + + public int getSelectedValue() { + return mPreferences.getInt(getKey(), mDefaultValue); + } + + /** + * 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 null if overwritten successfully otherwise; a newly created IntSetting. + */ + public void setSelectedValue(int selection) { + final SharedPreferences.Editor editor = mPreferences.edit(); + editor.putInt(getKey(), selection); + editor.apply(); + mView.showToastMessage(CitraApplication.getAppContext().getString(R.string.design_updated), false); + } + + @Override + public int getType() { + return TYPE_SINGLE_CHOICE; + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SettingsItem.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SettingsItem.java new file mode 100644 index 000000000..305352022 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SettingsItem.java @@ -0,0 +1,107 @@ +package org.citra.citra_emu.features.settings.model.view; + +import org.citra.citra_emu.features.settings.model.Setting; +import org.citra.citra_emu.features.settings.model.Settings; +import org.citra.citra_emu.features.settings.ui.SettingsAdapter; + +/** + * ViewModel abstraction for an Item in the RecyclerView powering SettingsFragments. + * Each one corresponds to a {@link Setting} 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.) + */ +public abstract class SettingsItem { + public static final int TYPE_HEADER = 0; + public static final int TYPE_CHECKBOX = 1; + public static final int TYPE_SINGLE_CHOICE = 2; + public static final int TYPE_SLIDER = 3; + public static final int TYPE_SUBMENU = 4; + public static final int TYPE_INPUT_BINDING = 5; + public static final int TYPE_STRING_SINGLE_CHOICE = 6; + public static final int TYPE_DATETIME_SETTING = 7; + public static final int TYPE_PREMIUM = 8; + + private String mKey; + private String mSection; + + private Setting mSetting; + + private int mNameId; + private int mDescriptionId; + private boolean mIsPremium; + + /** + * Base constructor. Takes a key / section name in case the third parameter, the Setting, + * is null; in which case, one can be constructed and saved using the key / section. + * + * @param key Identifier for the Setting represented by this Item. + * @param section Section to which the Setting belongs. + * @param setting A possibly-null backing Setting, to be modified on UI events. + * @param nameId Resource ID for a text string to be displayed as this setting's name. + * @param descriptionId Resource ID for a text string to be displayed as this setting's description. + */ + public SettingsItem(String key, String section, Setting setting, int nameId, + int descriptionId) { + mKey = key; + mSection = section; + mSetting = setting; + mNameId = nameId; + mDescriptionId = descriptionId; + mIsPremium = (section == Settings.SECTION_PREMIUM); + } + + /** + * @return The identifier for the backing Setting. + */ + public String getKey() { + return mKey; + } + + /** + * @return The header under which the backing Setting belongs. + */ + public String getSection() { + return mSection; + } + + /** + * @return The backing Setting, possibly null. + */ + public Setting getSetting() { + return mSetting; + } + + /** + * Replace the backing setting with a new one. Generally used in cases where + * the backing setting is null. + * + * @param setting A non-null Setting. + */ + public void setSetting(Setting setting) { + mSetting = setting; + } + + /** + * @return A resource ID for a text string representing this Setting's name. + */ + public int getNameId() { + return mNameId; + } + + public int getDescriptionId() { + return mDescriptionId; + } + + public boolean isPremium() { + return mIsPremium; + } + + /** + * Used by {@link SettingsAdapter}'s onCreateViewHolder() + * method to determine which type of ViewHolder should be created. + * + * @return An integer (ideally, one of the constants defined in this file) + */ + public abstract int getType(); +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SingleChoiceSetting.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SingleChoiceSetting.java new file mode 100644 index 000000000..ee9d225d6 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SingleChoiceSetting.java @@ -0,0 +1,60 @@ +package org.citra.citra_emu.features.settings.model.view; + +import org.citra.citra_emu.features.settings.model.IntSetting; +import org.citra.citra_emu.features.settings.model.Setting; + +public final class SingleChoiceSetting extends SettingsItem { + private int mDefaultValue; + + private int mChoicesId; + private int mValuesId; + + public SingleChoiceSetting(String key, String section, int titleId, int descriptionId, + int choicesId, int valuesId, int defaultValue, Setting setting) { + super(key, section, setting, titleId, descriptionId); + mValuesId = valuesId; + mChoicesId = choicesId; + mDefaultValue = defaultValue; + } + + public int getChoicesId() { + return mChoicesId; + } + + public int getValuesId() { + return mValuesId; + } + + public int getSelectedValue() { + if (getSetting() != null) { + IntSetting setting = (IntSetting) getSetting(); + return setting.getValue(); + } else { + return mDefaultValue; + } + } + + /** + * 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 null if overwritten successfully otherwise; a newly created IntSetting. + */ + public IntSetting setSelectedValue(int selection) { + if (getSetting() == null) { + IntSetting setting = new IntSetting(getKey(), getSection(), selection); + setSetting(setting); + return setting; + } else { + IntSetting setting = (IntSetting) getSetting(); + setting.setValue(selection); + return null; + } + } + + @Override + public int getType() { + return TYPE_SINGLE_CHOICE; + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SliderSetting.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SliderSetting.java new file mode 100644 index 000000000..551b13f99 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SliderSetting.java @@ -0,0 +1,101 @@ +package org.citra.citra_emu.features.settings.model.view; + +import org.citra.citra_emu.features.settings.model.FloatSetting; +import org.citra.citra_emu.features.settings.model.IntSetting; +import org.citra.citra_emu.features.settings.model.Setting; +import org.citra.citra_emu.utils.Log; + +public final class SliderSetting extends SettingsItem { + private int mMin; + private int mMax; + private int mDefaultValue; + + private String mUnits; + + public SliderSetting(String key, String section, int titleId, int descriptionId, + int min, int max, String units, int defaultValue, Setting setting) { + super(key, section, setting, titleId, descriptionId); + mMin = min; + mMax = max; + mUnits = units; + mDefaultValue = defaultValue; + } + + public int getMin() { + return mMin; + } + + public int getMax() { + return mMax; + } + + public int getDefaultValue() { + return mDefaultValue; + } + + public int getSelectedValue() { + Setting setting = getSetting(); + + if (setting == null) { + return mDefaultValue; + } + + if (setting instanceof IntSetting) { + IntSetting intSetting = (IntSetting) setting; + return intSetting.getValue(); + } else if (setting instanceof FloatSetting) { + FloatSetting floatSetting = (FloatSetting) setting; + return Math.round(floatSetting.getValue()); + } else { + Log.error("[SliderSetting] Error casting setting type."); + 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 null if overwritten successfully otherwise; a newly created IntSetting. + */ + public IntSetting setSelectedValue(int selection) { + if (getSetting() == null) { + IntSetting setting = new IntSetting(getKey(), getSection(), selection); + setSetting(setting); + return setting; + } else { + IntSetting setting = (IntSetting) getSetting(); + setting.setValue(selection); + return null; + } + } + + /** + * 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 null if overwritten successfully otherwise; a newly created FloatSetting. + */ + public FloatSetting setSelectedValue(float selection) { + if (getSetting() == null) { + FloatSetting setting = new FloatSetting(getKey(), getSection(), selection); + setSetting(setting); + return setting; + } else { + FloatSetting setting = (FloatSetting) getSetting(); + setting.setValue(selection); + return null; + } + } + + public String getUnits() { + return mUnits; + } + + @Override + public int getType() { + return TYPE_SLIDER; + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/StringSingleChoiceSetting.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/StringSingleChoiceSetting.java new file mode 100644 index 000000000..057145d9d --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/StringSingleChoiceSetting.java @@ -0,0 +1,82 @@ +package org.citra.citra_emu.features.settings.model.view; + +import org.citra.citra_emu.features.settings.model.Setting; +import org.citra.citra_emu.features.settings.model.StringSetting; + +public class StringSingleChoiceSetting extends SettingsItem { + private String mDefaultValue; + + private String[] mChoicesId; + private String[] mValuesId; + + public StringSingleChoiceSetting(String key, String section, int titleId, int descriptionId, + String[] choicesId, String[] valuesId, String defaultValue, Setting setting) { + super(key, section, setting, titleId, descriptionId); + mValuesId = valuesId; + mChoicesId = choicesId; + mDefaultValue = defaultValue; + } + + public String[] getChoicesId() { + return mChoicesId; + } + + public String[] getValuesId() { + return mValuesId; + } + + public String getValueAt(int index) { + if (mValuesId == null) + return null; + + if (index >= 0 && index < mValuesId.length) { + return mValuesId[index]; + } + + return ""; + } + + public String getSelectedValue() { + if (getSetting() != null) { + StringSetting setting = (StringSetting) getSetting(); + return setting.getValue(); + } else { + return mDefaultValue; + } + } + + public int getSelectValueIndex() { + String selectedValue = getSelectedValue(); + for (int i = 0; i < mValuesId.length; i++) { + if (mValuesId[i].equals(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 null if overwritten successfully otherwise; a newly created IntSetting. + */ + public StringSetting setSelectedValue(String selection) { + if (getSetting() == null) { + StringSetting setting = new StringSetting(getKey(), getSection(), selection); + setSetting(setting); + return setting; + } else { + StringSetting setting = (StringSetting) getSetting(); + setting.setValue(selection); + return null; + } + } + + @Override + public int getType() { + return TYPE_STRING_SINGLE_CHOICE; + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SubmenuSetting.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SubmenuSetting.java new file mode 100644 index 000000000..9d44a923f --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SubmenuSetting.java @@ -0,0 +1,21 @@ +package org.citra.citra_emu.features.settings.model.view; + +import org.citra.citra_emu.features.settings.model.Setting; + +public final class SubmenuSetting extends SettingsItem { + private String mMenuKey; + + public SubmenuSetting(String key, Setting setting, int titleId, int descriptionId, String menuKey) { + super(key, null, setting, titleId, descriptionId); + mMenuKey = menuKey; + } + + public String getMenuKey() { + return mMenuKey; + } + + @Override + public int getType() { + return TYPE_SUBMENU; + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivity.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivity.java new file mode 100644 index 000000000..23c3c4c9e --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivity.java @@ -0,0 +1,215 @@ +package org.citra.citra_emu.features.settings.ui; + +import android.app.ProgressDialog; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.os.Bundle; +import android.provider.Settings; +import android.view.Menu; +import android.view.MenuInflater; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatActivity; +import androidx.fragment.app.FragmentTransaction; +import androidx.localbroadcastmanager.content.LocalBroadcastManager; + +import org.citra.citra_emu.NativeLibrary; +import org.citra.citra_emu.R; +import org.citra.citra_emu.utils.DirectoryInitialization; +import org.citra.citra_emu.utils.DirectoryStateReceiver; +import org.citra.citra_emu.utils.EmulationMenuSettings; + +public final class SettingsActivity extends AppCompatActivity implements SettingsActivityView { + private static final String ARG_MENU_TAG = "menu_tag"; + private static final String ARG_GAME_ID = "game_id"; + private static final String FRAGMENT_TAG = "settings"; + private SettingsActivityPresenter mPresenter = new SettingsActivityPresenter(this); + + private ProgressDialog dialog; + + public static void launch(Context context, String menuTag, String gameId) { + Intent settings = new Intent(context, SettingsActivity.class); + settings.putExtra(ARG_MENU_TAG, menuTag); + settings.putExtra(ARG_GAME_ID, gameId); + context.startActivity(settings); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + setContentView(R.layout.activity_settings); + + Intent launcher = getIntent(); + String gameID = launcher.getStringExtra(ARG_GAME_ID); + String menuTag = launcher.getStringExtra(ARG_MENU_TAG); + + mPresenter.onCreate(savedInstanceState, menuTag, gameID); + + // Show "Back" button in the action bar for navigation + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + } + + @Override + public boolean onSupportNavigateUp() { + onBackPressed(); + + return true; + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + MenuInflater inflater = getMenuInflater(); + inflater.inflate(R.menu.menu_settings, menu); + + return true; + } + + @Override + protected void onSaveInstanceState(@NonNull Bundle outState) { + // Critical: If super method is not called, rotations will be busted. + super.onSaveInstanceState(outState); + mPresenter.saveState(outState); + } + + @Override + protected void onStart() { + super.onStart(); + mPresenter.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 + protected void onStop() { + super.onStop(); + + mPresenter.onStop(isFinishing()); + + // Update framebuffer layout when closing the settings + NativeLibrary.NotifyOrientationChange(EmulationMenuSettings.getLandscapeScreenLayout(), + getWindowManager().getDefaultDisplay().getRotation()); + } + + @Override + public void showSettingsFragment(String menuTag, boolean addToStack, String gameID) { + if (!addToStack && getFragment() != null) { + return; + } + + FragmentTransaction transaction = getSupportFragmentManager().beginTransaction(); + + if (addToStack) { + if (areSystemAnimationsEnabled()) { + transaction.setCustomAnimations( + R.animator.settings_enter, + R.animator.settings_exit, + R.animator.settings_pop_enter, + R.animator.setttings_pop_exit); + } + + transaction.addToBackStack(null); + } + transaction.replace(R.id.frame_content, SettingsFragment.newInstance(menuTag, gameID), FRAGMENT_TAG); + + transaction.commit(); + } + + private boolean areSystemAnimationsEnabled() { + float duration = Settings.Global.getFloat( + getContentResolver(), + Settings.Global.ANIMATOR_DURATION_SCALE, 1); + float transition = Settings.Global.getFloat( + getContentResolver(), + Settings.Global.TRANSITION_ANIMATION_SCALE, 1); + return duration != 0 && transition != 0; + } + + @Override + public void startDirectoryInitializationService(DirectoryStateReceiver receiver, IntentFilter filter) { + LocalBroadcastManager.getInstance(this).registerReceiver( + receiver, + filter); + DirectoryInitialization.start(this); + } + + @Override + public void stopListeningToDirectoryInitializationService(DirectoryStateReceiver receiver) { + LocalBroadcastManager.getInstance(this).unregisterReceiver(receiver); + } + + @Override + public void showLoading() { + if (dialog == null) { + dialog = new ProgressDialog(this); + dialog.setMessage(getString(R.string.load_settings)); + dialog.setIndeterminate(true); + } + + dialog.show(); + } + + @Override + public void hideLoading() { + dialog.dismiss(); + } + + @Override + public void showPermissionNeededHint() { + Toast.makeText(this, R.string.write_permission_needed, Toast.LENGTH_SHORT) + .show(); + } + + @Override + public void showExternalStorageNotMountedHint() { + Toast.makeText(this, R.string.external_storage_not_mounted, Toast.LENGTH_SHORT) + .show(); + } + + @Override + public org.citra.citra_emu.features.settings.model.Settings getSettings() { + return mPresenter.getSettings(); + } + + @Override + public void setSettings(org.citra.citra_emu.features.settings.model.Settings settings) { + mPresenter.setSettings(settings); + } + + @Override + public void onSettingsFileLoaded(org.citra.citra_emu.features.settings.model.Settings settings) { + SettingsFragmentView fragment = getFragment(); + + if (fragment != null) { + fragment.onSettingsFileLoaded(settings); + } + } + + @Override + public void onSettingsFileNotFound() { + SettingsFragmentView fragment = getFragment(); + + if (fragment != null) { + fragment.loadDefaultSettings(); + } + } + + @Override + public void showToastMessage(String message, boolean is_long) { + Toast.makeText(this, message, is_long ? Toast.LENGTH_LONG : Toast.LENGTH_SHORT).show(); + } + + @Override + public void onSettingChanged() { + mPresenter.onSettingChanged(); + } + + private SettingsFragment getFragment() { + return (SettingsFragment) getSupportFragmentManager().findFragmentByTag(FRAGMENT_TAG); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivityPresenter.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivityPresenter.java new file mode 100644 index 000000000..0d63873bb --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivityPresenter.java @@ -0,0 +1,124 @@ +package org.citra.citra_emu.features.settings.ui; + +import android.content.IntentFilter; +import android.os.Bundle; +import android.text.TextUtils; + +import org.citra.citra_emu.NativeLibrary; +import org.citra.citra_emu.features.settings.model.Settings; +import org.citra.citra_emu.features.settings.utils.SettingsFile; +import org.citra.citra_emu.utils.DirectoryInitialization; +import org.citra.citra_emu.utils.DirectoryInitialization.DirectoryInitializationState; +import org.citra.citra_emu.utils.DirectoryStateReceiver; +import org.citra.citra_emu.utils.Log; +import org.citra.citra_emu.utils.ThemeUtil; + +import java.io.File; + +public final class SettingsActivityPresenter { + private static final String KEY_SHOULD_SAVE = "should_save"; + + private SettingsActivityView mView; + + private Settings mSettings = new Settings(); + + private boolean mShouldSave; + + private DirectoryStateReceiver directoryStateReceiver; + + private String menuTag; + private String gameId; + + public SettingsActivityPresenter(SettingsActivityView view) { + mView = view; + } + + public void onCreate(Bundle savedInstanceState, String menuTag, String gameId) { + if (savedInstanceState == null) { + this.menuTag = menuTag; + this.gameId = gameId; + } else { + mShouldSave = savedInstanceState.getBoolean(KEY_SHOULD_SAVE); + } + } + + public void onStart() { + prepareCitraDirectoriesIfNeeded(); + } + + void loadSettingsUI() { + if (mSettings.isEmpty()) { + if (!TextUtils.isEmpty(gameId)) { + mSettings.loadSettings(gameId, mView); + } else { + mSettings.loadSettings(mView); + } + } + + mView.showSettingsFragment(menuTag, false, gameId); + mView.onSettingsFileLoaded(mSettings); + } + + private void prepareCitraDirectoriesIfNeeded() { + File configFile = new File(DirectoryInitialization.getUserDirectory() + "/config/" + SettingsFile.FILE_NAME_CONFIG + ".ini"); + if (!configFile.exists()) { + Log.error("Citra config file could not be found!"); + } + if (DirectoryInitialization.areCitraDirectoriesReady()) { + loadSettingsUI(); + } else { + mView.showLoading(); + IntentFilter statusIntentFilter = new IntentFilter( + DirectoryInitialization.BROADCAST_ACTION); + + directoryStateReceiver = + new DirectoryStateReceiver(directoryInitializationState -> + { + if (directoryInitializationState == DirectoryInitializationState.CITRA_DIRECTORIES_INITIALIZED) { + mView.hideLoading(); + loadSettingsUI(); + } else if (directoryInitializationState == DirectoryInitializationState.EXTERNAL_STORAGE_PERMISSION_NEEDED) { + mView.showPermissionNeededHint(); + mView.hideLoading(); + } else if (directoryInitializationState == DirectoryInitializationState.CANT_FIND_EXTERNAL_STORAGE) { + mView.showExternalStorageNotMountedHint(); + mView.hideLoading(); + } + }); + + mView.startDirectoryInitializationService(directoryStateReceiver, statusIntentFilter); + } + } + + public void setSettings(Settings settings) { + mSettings = settings; + } + + public Settings getSettings() { + return mSettings; + } + + public void onStop(boolean finishing) { + if (directoryStateReceiver != null) { + mView.stopListeningToDirectoryInitializationService(directoryStateReceiver); + directoryStateReceiver = null; + } + + if (mSettings != null && finishing && mShouldSave) { + Log.debug("[SettingsActivity] Settings activity stopping. Saving settings to INI..."); + mSettings.saveSettings(mView); + } + + ThemeUtil.applyTheme(); + + NativeLibrary.ReloadSettings(); + } + + public void onSettingChanged() { + mShouldSave = true; + } + + public void saveState(Bundle outState) { + outState.putBoolean(KEY_SHOULD_SAVE, mShouldSave); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivityView.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivityView.java new file mode 100644 index 000000000..0d26d48a7 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivityView.java @@ -0,0 +1,103 @@ +package org.citra.citra_emu.features.settings.ui; + +import android.content.IntentFilter; + +import org.citra.citra_emu.features.settings.model.Settings; +import org.citra.citra_emu.utils.DirectoryStateReceiver; + +/** + * Abstraction for the Activity that manages SettingsFragments. + */ +public 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. + */ + void showSettingsFragment(String menuTag, boolean addToStack, String gameId); + + /** + * 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 possibly null HashMap of Settings. + */ + Settings getSettings(); + + /** + * Used to provide the Activity with Settings HashMaps if a Fragment already + * has one; for example, if a rotation occurs, the Fragment will not be killed, + * but the Activity will, so the Activity needs to have its HashMaps resupplied. + * + * @param settings The ArrayList of all the Settings HashMaps. + */ + void setSettings(Settings settings); + + /** + * Called when an asynchronous load operation completes. + * + * @param settings The (possibly null) result of the ini load operation. + */ + void onSettingsFileLoaded(Settings settings); + + /** + * Called when an asynchronous load operation fails. + */ + void 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. + */ + void showToastMessage(String message, boolean is_long); + + /** + * End the activity. + */ + void 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. + */ + void onSettingChanged(); + + /** + * Show loading dialog while loading the settings + */ + void showLoading(); + + /** + * Hide the loading the dialog + */ + void hideLoading(); + + /** + * Show a hint to the user that the app needs write to external storage access + */ + void showPermissionNeededHint(); + + /** + * Show a hint to the user that the app needs the external storage to be mounted + */ + void showExternalStorageNotMountedHint(); + + /** + * Start the DirectoryInitialization and listen for the result. + * + * @param receiver the broadcast receiver for the DirectoryInitialization + * @param filter the Intent broadcasts to be received. + */ + void startDirectoryInitializationService(DirectoryStateReceiver receiver, IntentFilter filter); + + /** + * Stop listening to the DirectoryInitialization. + * + * @param receiver The broadcast receiver to unregister. + */ + void stopListeningToDirectoryInitializationService(DirectoryStateReceiver receiver); +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsAdapter.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsAdapter.java new file mode 100644 index 000000000..bfd7c71a9 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsAdapter.java @@ -0,0 +1,487 @@ +package org.citra.citra_emu.features.settings.ui; + +import android.content.Context; +import android.content.DialogInterface; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.DatePicker; +import android.widget.SeekBar; +import android.widget.TextView; +import android.widget.TimePicker; + +import androidx.appcompat.app.AlertDialog; +import androidx.recyclerview.widget.RecyclerView; + +import org.citra.citra_emu.R; +import org.citra.citra_emu.dialogs.MotionAlertDialog; +import org.citra.citra_emu.features.settings.model.FloatSetting; +import org.citra.citra_emu.features.settings.model.IntSetting; +import org.citra.citra_emu.features.settings.model.StringSetting; +import org.citra.citra_emu.features.settings.model.view.CheckBoxSetting; +import org.citra.citra_emu.features.settings.model.view.DateTimeSetting; +import org.citra.citra_emu.features.settings.model.view.InputBindingSetting; +import org.citra.citra_emu.features.settings.model.view.PremiumSingleChoiceSetting; +import org.citra.citra_emu.features.settings.model.view.SettingsItem; +import org.citra.citra_emu.features.settings.model.view.SingleChoiceSetting; +import org.citra.citra_emu.features.settings.model.view.SliderSetting; +import org.citra.citra_emu.features.settings.model.view.StringSingleChoiceSetting; +import org.citra.citra_emu.features.settings.model.view.SubmenuSetting; +import org.citra.citra_emu.features.settings.ui.viewholder.CheckBoxSettingViewHolder; +import org.citra.citra_emu.features.settings.ui.viewholder.DateTimeViewHolder; +import org.citra.citra_emu.features.settings.ui.viewholder.HeaderViewHolder; +import org.citra.citra_emu.features.settings.ui.viewholder.InputBindingSettingViewHolder; +import org.citra.citra_emu.features.settings.ui.viewholder.PremiumViewHolder; +import org.citra.citra_emu.features.settings.ui.viewholder.SettingViewHolder; +import org.citra.citra_emu.features.settings.ui.viewholder.SingleChoiceViewHolder; +import org.citra.citra_emu.features.settings.ui.viewholder.SliderViewHolder; +import org.citra.citra_emu.features.settings.ui.viewholder.SubmenuViewHolder; +import org.citra.citra_emu.ui.main.MainActivity; +import org.citra.citra_emu.utils.Log; + +import java.util.ArrayList; + +public final class SettingsAdapter extends RecyclerView.Adapter + implements DialogInterface.OnClickListener, SeekBar.OnSeekBarChangeListener { + private SettingsFragmentView mView; + private Context mContext; + private ArrayList mSettings; + + private SettingsItem mClickedItem; + private int mClickedPosition; + private int mSeekbarProgress; + + private AlertDialog mDialog; + private TextView mTextSliderValue; + + public SettingsAdapter(SettingsFragmentView view, Context context) { + mView = view; + mContext = context; + mClickedPosition = -1; + } + + @Override + public SettingViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + View view; + LayoutInflater inflater = LayoutInflater.from(parent.getContext()); + + switch (viewType) { + case SettingsItem.TYPE_HEADER: + view = inflater.inflate(R.layout.list_item_settings_header, parent, false); + return new HeaderViewHolder(view, this); + + case SettingsItem.TYPE_CHECKBOX: + view = inflater.inflate(R.layout.list_item_setting_checkbox, parent, false); + return new CheckBoxSettingViewHolder(view, this); + + case SettingsItem.TYPE_SINGLE_CHOICE: + case SettingsItem.TYPE_STRING_SINGLE_CHOICE: + view = inflater.inflate(R.layout.list_item_setting, parent, false); + return new SingleChoiceViewHolder(view, this); + + case SettingsItem.TYPE_SLIDER: + view = inflater.inflate(R.layout.list_item_setting, parent, false); + return new SliderViewHolder(view, this); + + case SettingsItem.TYPE_SUBMENU: + view = inflater.inflate(R.layout.list_item_setting, parent, false); + return new SubmenuViewHolder(view, this); + + case SettingsItem.TYPE_INPUT_BINDING: + view = inflater.inflate(R.layout.list_item_setting, parent, false); + return new InputBindingSettingViewHolder(view, this, mContext); + + case SettingsItem.TYPE_DATETIME_SETTING: + view = inflater.inflate(R.layout.list_item_setting, parent, false); + return new DateTimeViewHolder(view, this); + + case SettingsItem.TYPE_PREMIUM: + view = inflater.inflate(R.layout.premium_item_setting, parent, false); + return new PremiumViewHolder(view, this, mView); + + default: + Log.error("[SettingsAdapter] Invalid view type: " + viewType); + return null; + } + } + + @Override + public void onBindViewHolder(SettingViewHolder holder, int position) { + holder.bind(getItem(position)); + } + + private SettingsItem getItem(int position) { + return mSettings.get(position); + } + + @Override + public int getItemCount() { + if (mSettings != null) { + return mSettings.size(); + } else { + return 0; + } + } + + @Override + public int getItemViewType(int position) { + return getItem(position).getType(); + } + + public void setSettings(ArrayList settings) { + mSettings = settings; + notifyDataSetChanged(); + } + + public void onBooleanClick(CheckBoxSetting item, int position, boolean checked) { + IntSetting setting = item.setChecked(checked); + notifyItemChanged(position); + + if (setting != null) { + mView.putSetting(setting); + } + + mView.onSettingChanged(); + } + + public void onSingleChoiceClick(PremiumSingleChoiceSetting item) { + mClickedItem = item; + + int value = getSelectionForSingleChoiceValue(item); + + AlertDialog.Builder builder = new AlertDialog.Builder(mView.getActivity()); + + builder.setTitle(item.getNameId()); + builder.setSingleChoiceItems(item.getChoicesId(), value, this); + + mDialog = builder.show(); + } + + public void onSingleChoiceClick(SingleChoiceSetting item) { + mClickedItem = item; + + int value = getSelectionForSingleChoiceValue(item); + + AlertDialog.Builder builder = new AlertDialog.Builder(mView.getActivity()); + + builder.setTitle(item.getNameId()); + builder.setSingleChoiceItems(item.getChoicesId(), value, this); + + mDialog = builder.show(); + } + + public void onSingleChoiceClick(SingleChoiceSetting item, int position) { + mClickedPosition = position; + + if (!item.isPremium() || MainActivity.isPremiumActive()) { + // Setting is either not Premium, or the user has Premium + onSingleChoiceClick(item); + return; + } + + // User needs Premium, invoke the billing flow + MainActivity.invokePremiumBilling(() -> onSingleChoiceClick(item)); + } + + public void onSingleChoiceClick(PremiumSingleChoiceSetting item, int position) { + mClickedPosition = position; + + if (!item.isPremium() || MainActivity.isPremiumActive()) { + // Setting is either not Premium, or the user has Premium + onSingleChoiceClick(item); + return; + } + + // User needs Premium, invoke the billing flow + MainActivity.invokePremiumBilling(() -> onSingleChoiceClick(item)); + } + + public void onStringSingleChoiceClick(StringSingleChoiceSetting item) { + mClickedItem = item; + + AlertDialog.Builder builder = new AlertDialog.Builder(mView.getActivity()); + + builder.setTitle(item.getNameId()); + builder.setSingleChoiceItems(item.getChoicesId(), item.getSelectValueIndex(), this); + + mDialog = builder.show(); + } + + public void onStringSingleChoiceClick(StringSingleChoiceSetting item, int position) { + mClickedPosition = position; + + if (!item.isPremium() || MainActivity.isPremiumActive()) { + // Setting is either not Premium, or the user has Premium + onStringSingleChoiceClick(item); + return; + } + + // User needs Premium, invoke the billing flow + MainActivity.invokePremiumBilling(() -> onStringSingleChoiceClick(item)); + } + + DialogInterface.OnClickListener defaultCancelListener = (dialog, which) -> closeDialog(); + + public void onDateTimeClick(DateTimeSetting item, int position) { + mClickedItem = item; + mClickedPosition = position; + + AlertDialog.Builder builder = new AlertDialog.Builder(mView.getActivity()); + + LayoutInflater inflater = LayoutInflater.from(mView.getActivity()); + View view = inflater.inflate(R.layout.sysclock_datetime_picker, null); + + DatePicker dp = view.findViewById(R.id.date_picker); + TimePicker tp = view.findViewById(R.id.time_picker); + + //set date and time to substrings of settingValue; format = 2018-12-24 04:20:69 (alright maybe not that 69) + String settingValue = item.getValue(); + dp.updateDate(Integer.parseInt(settingValue.substring(0, 4)), Integer.parseInt(settingValue.substring(5, 7)) - 1, Integer.parseInt(settingValue.substring(8, 10))); + + tp.setIs24HourView(true); + tp.setHour(Integer.parseInt(settingValue.substring(11, 13))); + tp.setMinute(Integer.parseInt(settingValue.substring(14, 16))); + + DialogInterface.OnClickListener ok = (dialog, which) -> { + //set it + int year = dp.getYear(); + if (year < 2000) { + year = 2000; + } + String month = ("00" + (dp.getMonth() + 1)).substring(String.valueOf(dp.getMonth() + 1).length()); + String day = ("00" + dp.getDayOfMonth()).substring(String.valueOf(dp.getDayOfMonth()).length()); + String hr = ("00" + tp.getHour()).substring(String.valueOf(tp.getHour()).length()); + String min = ("00" + tp.getMinute()).substring(String.valueOf(tp.getMinute()).length()); + String datetime = year + "-" + month + "-" + day + " " + hr + ":" + min + ":01"; + + StringSetting setting = item.setSelectedValue(datetime); + if (setting != null) { + mView.putSetting(setting); + } + + mView.onSettingChanged(); + + mClickedItem = null; + closeDialog(); + }; + + builder.setView(view); + builder.setPositiveButton(android.R.string.ok, ok); + builder.setNegativeButton(android.R.string.cancel, defaultCancelListener); + mDialog = builder.show(); + } + + public void onSliderClick(SliderSetting item, int position) { + mClickedItem = item; + mClickedPosition = position; + mSeekbarProgress = item.getSelectedValue(); + AlertDialog.Builder builder = new AlertDialog.Builder(mView.getActivity()); + + LayoutInflater inflater = LayoutInflater.from(mView.getActivity()); + View view = inflater.inflate(R.layout.dialog_seekbar, null); + + SeekBar seekbar = view.findViewById(R.id.seekbar); + + builder.setTitle(item.getNameId()); + builder.setView(view); + builder.setPositiveButton(android.R.string.ok, this); + builder.setNegativeButton(android.R.string.cancel, defaultCancelListener); + builder.setNeutralButton(R.string.slider_default, (DialogInterface dialog, int which) -> { + seekbar.setProgress(item.getDefaultValue()); + onClick(dialog, which); + }); + mDialog = builder.show(); + + mTextSliderValue = view.findViewById(R.id.text_value); + mTextSliderValue.setText(String.valueOf(mSeekbarProgress)); + + TextView units = view.findViewById(R.id.text_units); + units.setText(item.getUnits()); + + seekbar.setMin(item.getMin()); + seekbar.setMax(item.getMax()); + seekbar.setProgress(mSeekbarProgress); + + seekbar.setOnSeekBarChangeListener(this); + } + + public void onSubmenuClick(SubmenuSetting item) { + mView.loadSubMenu(item.getMenuKey()); + } + + public void onInputBindingClick(final InputBindingSetting item, final int position) { + final MotionAlertDialog dialog = new MotionAlertDialog(mContext, item); + dialog.setTitle(R.string.input_binding); + + int messageResId = R.string.input_binding_description; + if (item.IsAxisMappingSupported() && !item.IsTrigger()) { + // Use specialized message for axis left/right or up/down + if (item.IsHorizontalOrientation()) { + messageResId = R.string.input_binding_description_horizontal_axis; + } else { + messageResId = R.string.input_binding_description_vertical_axis; + } + } + + dialog.setMessage(String.format(mContext.getString(messageResId), mContext.getString(item.getNameId()))); + dialog.setButton(AlertDialog.BUTTON_NEGATIVE, mContext.getString(android.R.string.cancel), this); + dialog.setButton(AlertDialog.BUTTON_NEUTRAL, mContext.getString(R.string.clear), (dialogInterface, i) -> + item.removeOldMapping()); + dialog.setOnDismissListener(dialog1 -> + { + StringSetting setting = new StringSetting(item.getKey(), item.getSection(), item.getValue()); + notifyItemChanged(position); + + mView.putSetting(setting); + + mView.onSettingChanged(); + }); + dialog.setCanceledOnTouchOutside(false); + dialog.show(); + } + + @Override + public void onClick(DialogInterface dialog, int which) { + if (mClickedItem instanceof SingleChoiceSetting) { + SingleChoiceSetting scSetting = (SingleChoiceSetting) mClickedItem; + + int value = getValueForSingleChoiceSelection(scSetting, which); + if (scSetting.getSelectedValue() != value) { + mView.onSettingChanged(); + } + + // Get the backing Setting, which may be null (if for example it was missing from the file) + IntSetting setting = scSetting.setSelectedValue(value); + if (setting != null) { + mView.putSetting(setting); + } + + closeDialog(); + } else if (mClickedItem instanceof PremiumSingleChoiceSetting) { + PremiumSingleChoiceSetting scSetting = (PremiumSingleChoiceSetting) mClickedItem; + scSetting.setSelectedValue(getValueForSingleChoiceSelection(scSetting, which)); + closeDialog(); + } else if (mClickedItem instanceof StringSingleChoiceSetting) { + StringSingleChoiceSetting scSetting = (StringSingleChoiceSetting) mClickedItem; + String value = scSetting.getValueAt(which); + if (!scSetting.getSelectedValue().equals(value)) + mView.onSettingChanged(); + + StringSetting setting = scSetting.setSelectedValue(value); + if (setting != null) { + mView.putSetting(setting); + } + + closeDialog(); + } else if (mClickedItem instanceof SliderSetting) { + SliderSetting sliderSetting = (SliderSetting) mClickedItem; + if (sliderSetting.getSelectedValue() != mSeekbarProgress) { + mView.onSettingChanged(); + } + + if (sliderSetting.getSetting() instanceof FloatSetting) { + float value = (float) mSeekbarProgress; + + FloatSetting setting = sliderSetting.setSelectedValue(value); + if (setting != null) { + mView.putSetting(setting); + } + } else { + IntSetting setting = sliderSetting.setSelectedValue(mSeekbarProgress); + if (setting != null) { + mView.putSetting(setting); + } + } + + closeDialog(); + } + + mClickedItem = null; + mSeekbarProgress = -1; + } + + public void closeDialog() { + if (mDialog != null) { + if (mClickedPosition != -1) { + notifyItemChanged(mClickedPosition); + mClickedPosition = -1; + } + mDialog.dismiss(); + mDialog = null; + } + } + + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + mSeekbarProgress = progress; + mTextSliderValue.setText(String.valueOf(mSeekbarProgress)); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + } + + private int getValueForSingleChoiceSelection(SingleChoiceSetting item, int which) { + int valuesId = item.getValuesId(); + + if (valuesId > 0) { + int[] valuesArray = mContext.getResources().getIntArray(valuesId); + return valuesArray[which]; + } else { + return which; + } + } + + private int getValueForSingleChoiceSelection(PremiumSingleChoiceSetting item, int which) { + int valuesId = item.getValuesId(); + + if (valuesId > 0) { + int[] valuesArray = mContext.getResources().getIntArray(valuesId); + return valuesArray[which]; + } else { + return which; + } + } + + private int getSelectionForSingleChoiceValue(SingleChoiceSetting item) { + int value = item.getSelectedValue(); + int valuesId = item.getValuesId(); + + if (valuesId > 0) { + int[] valuesArray = mContext.getResources().getIntArray(valuesId); + for (int index = 0; index < valuesArray.length; index++) { + int current = valuesArray[index]; + if (current == value) { + return index; + } + } + } else { + return value; + } + + return -1; + } + + private int getSelectionForSingleChoiceValue(PremiumSingleChoiceSetting item) { + int value = item.getSelectedValue(); + int valuesId = item.getValuesId(); + + if (valuesId > 0) { + int[] valuesArray = mContext.getResources().getIntArray(valuesId); + for (int index = 0; index < valuesArray.length; index++) { + int current = valuesArray[index]; + if (current == value) { + return index; + } + } + } else { + return value; + } + + return -1; + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragment.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragment.java new file mode 100644 index 000000000..5799dcb8d --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragment.java @@ -0,0 +1,136 @@ +package org.citra.citra_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.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.settings.model.Setting; +import org.citra.citra_emu.features.settings.model.Settings; +import org.citra.citra_emu.features.settings.model.view.SettingsItem; +import org.citra.citra_emu.ui.DividerItemDecoration; + +import java.util.ArrayList; + +public final class SettingsFragment extends Fragment implements SettingsFragmentView { + private static final String ARGUMENT_MENU_TAG = "menu_tag"; + private static final String ARGUMENT_GAME_ID = "game_id"; + + private SettingsFragmentPresenter mPresenter = new SettingsFragmentPresenter(this); + private SettingsActivityView mActivity; + + private SettingsAdapter mAdapter; + + public static Fragment newInstance(String menuTag, String gameId) { + SettingsFragment fragment = new SettingsFragment(); + + Bundle arguments = new Bundle(); + arguments.putString(ARGUMENT_MENU_TAG, menuTag); + arguments.putString(ARGUMENT_GAME_ID, gameId); + + fragment.setArguments(arguments); + return fragment; + } + + @Override + public void onAttach(@NonNull Context context) { + super.onAttach(context); + + mActivity = (SettingsActivityView) context; + mPresenter.onAttach(); + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + setRetainInstance(true); + String menuTag = getArguments().getString(ARGUMENT_MENU_TAG); + String gameId = getArguments().getString(ARGUMENT_GAME_ID); + + mAdapter = new SettingsAdapter(this, getActivity()); + + mPresenter.onCreate(menuTag, gameId); + } + + @Nullable + @Override + public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.fragment_settings, container, false); + } + + @Override + public void onViewCreated(View view, @Nullable Bundle savedInstanceState) { + LinearLayoutManager manager = new LinearLayoutManager(getActivity()); + + RecyclerView recyclerView = view.findViewById(R.id.list_settings); + + recyclerView.setAdapter(mAdapter); + recyclerView.setLayoutManager(manager); + recyclerView.addItemDecoration(new DividerItemDecoration(getActivity(), null)); + + SettingsActivityView activity = (SettingsActivityView) getActivity(); + + mPresenter.onViewCreated(activity.getSettings()); + } + + @Override + public void onDetach() { + super.onDetach(); + mActivity = null; + + if (mAdapter != null) { + mAdapter.closeDialog(); + } + } + + @Override + public void onSettingsFileLoaded(Settings settings) { + mPresenter.setSettings(settings); + } + + @Override + public void passSettingsToActivity(Settings settings) { + if (mActivity != null) { + mActivity.setSettings(settings); + } + } + + @Override + public void showSettingsList(ArrayList settingsList) { + mAdapter.setSettings(settingsList); + } + + @Override + public void loadDefaultSettings() { + mPresenter.loadDefaultSettings(); + } + + @Override + public void loadSubMenu(String menuKey) { + mActivity.showSettingsFragment(menuKey, true, getArguments().getString(ARGUMENT_GAME_ID)); + } + + @Override + public void showToastMessage(String message, boolean is_long) { + mActivity.showToastMessage(message, is_long); + } + + @Override + public void putSetting(Setting setting) { + mPresenter.putSetting(setting); + } + + @Override + public void onSettingChanged() { + mActivity.onSettingChanged(); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentPresenter.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentPresenter.java new file mode 100644 index 000000000..ef4623914 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentPresenter.java @@ -0,0 +1,409 @@ +package org.citra.citra_emu.features.settings.ui; + +import android.app.Activity; +import android.content.Context; +import android.hardware.camera2.CameraAccessException; +import android.hardware.camera2.CameraCharacteristics; +import android.hardware.camera2.CameraManager; +import android.text.TextUtils; + +import org.citra.citra_emu.NativeLibrary; +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.settings.model.Setting; +import org.citra.citra_emu.features.settings.model.SettingSection; +import org.citra.citra_emu.features.settings.model.Settings; +import org.citra.citra_emu.features.settings.model.StringSetting; +import org.citra.citra_emu.features.settings.model.view.CheckBoxSetting; +import org.citra.citra_emu.features.settings.model.view.DateTimeSetting; +import org.citra.citra_emu.features.settings.model.view.HeaderSetting; +import org.citra.citra_emu.features.settings.model.view.InputBindingSetting; +import org.citra.citra_emu.features.settings.model.view.PremiumHeader; +import org.citra.citra_emu.features.settings.model.view.PremiumSingleChoiceSetting; +import org.citra.citra_emu.features.settings.model.view.SettingsItem; +import org.citra.citra_emu.features.settings.model.view.SingleChoiceSetting; +import org.citra.citra_emu.features.settings.model.view.SliderSetting; +import org.citra.citra_emu.features.settings.model.view.StringSingleChoiceSetting; +import org.citra.citra_emu.features.settings.model.view.SubmenuSetting; +import org.citra.citra_emu.features.settings.utils.SettingsFile; +import org.citra.citra_emu.utils.Log; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Objects; + +public final class SettingsFragmentPresenter { + private SettingsFragmentView mView; + + private String mMenuTag; + private String mGameID; + + private Settings mSettings; + private ArrayList mSettingsList; + + public SettingsFragmentPresenter(SettingsFragmentView view) { + mView = view; + } + + public void onCreate(String menuTag, String gameId) { + mGameID = gameId; + mMenuTag = menuTag; + } + + public void onViewCreated(Settings settings) { + setSettings(settings); + } + + /** + * If the screen is rotated, the Activity will forget the settings map. This fragment + * won't, though; so rather than have the Activity reload from disk, have the fragment pass + * the settings map back to the Activity. + */ + public void onAttach() { + if (mSettings != null) { + mView.passSettingsToActivity(mSettings); + } + } + + public void putSetting(Setting setting) { + mSettings.getSection(setting.getSection()).putSetting(setting); + } + + private StringSetting asStringSetting(Setting setting) { + if (setting == null) { + return null; + } + + StringSetting stringSetting = new StringSetting(setting.getKey(), setting.getSection(), setting.getValueAsString()); + putSetting(stringSetting); + return stringSetting; + } + + public void loadDefaultSettings() { + loadSettingsList(); + } + + public void setSettings(Settings settings) { + if (mSettingsList == null && settings != null) { + mSettings = settings; + + loadSettingsList(); + } else { + mView.getActivity().setTitle(R.string.preferences_settings); + mView.showSettingsList(mSettingsList); + } + } + + private void loadSettingsList() { + if (!TextUtils.isEmpty(mGameID)) { + mView.getActivity().setTitle("Game Settings: " + mGameID); + } + ArrayList sl = new ArrayList<>(); + + if (mMenuTag == null) { + return; + } + + switch (mMenuTag) { + case SettingsFile.FILE_NAME_CONFIG: + addConfigSettings(sl); + break; + case Settings.SECTION_PREMIUM: + addPremiumSettings(sl); + break; + case Settings.SECTION_CORE: + addGeneralSettings(sl); + break; + case Settings.SECTION_SYSTEM: + addSystemSettings(sl); + break; + case Settings.SECTION_CAMERA: + addCameraSettings(sl); + break; + case Settings.SECTION_CONTROLS: + addInputSettings(sl); + break; + case Settings.SECTION_RENDERER: + addGraphicsSettings(sl); + break; + case Settings.SECTION_AUDIO: + addAudioSettings(sl); + break; + case Settings.SECTION_DEBUG: + addDebugSettings(sl); + break; + default: + mView.showToastMessage("Unimplemented menu", false); + return; + } + + mSettingsList = sl; + mView.showSettingsList(mSettingsList); + } + + private void addConfigSettings(ArrayList sl) { + mView.getActivity().setTitle(R.string.preferences_settings); + + sl.add(new SubmenuSetting(null, null, R.string.preferences_premium, 0, Settings.SECTION_PREMIUM)); + sl.add(new SubmenuSetting(null, null, R.string.preferences_general, 0, Settings.SECTION_CORE)); + sl.add(new SubmenuSetting(null, null, R.string.preferences_system, 0, Settings.SECTION_SYSTEM)); + sl.add(new SubmenuSetting(null, null, R.string.preferences_camera, 0, Settings.SECTION_CAMERA)); + sl.add(new SubmenuSetting(null, null, R.string.preferences_controls, 0, Settings.SECTION_CONTROLS)); + sl.add(new SubmenuSetting(null, null, R.string.preferences_graphics, 0, Settings.SECTION_RENDERER)); + sl.add(new SubmenuSetting(null, null, R.string.preferences_audio, 0, Settings.SECTION_AUDIO)); + sl.add(new SubmenuSetting(null, null, R.string.preferences_debug, 0, Settings.SECTION_DEBUG)); + } + + private void addPremiumSettings(ArrayList sl) { + mView.getActivity().setTitle(R.string.preferences_premium); + + SettingSection premiumSection = mSettings.getSection(Settings.SECTION_PREMIUM); + Setting design = premiumSection.getSetting(SettingsFile.KEY_DESIGN); + + sl.add(new PremiumHeader()); + + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) { + sl.add(new PremiumSingleChoiceSetting(SettingsFile.KEY_DESIGN, Settings.SECTION_PREMIUM, R.string.design, 0, R.array.designNames, R.array.designValues, 0, design, mView)); + } else { + // Pre-Android 10 does not support System Default + sl.add(new PremiumSingleChoiceSetting(SettingsFile.KEY_DESIGN, Settings.SECTION_PREMIUM, R.string.design, 0, R.array.designNamesOld, R.array.designValuesOld, 0, design, mView)); + } + + String[] textureFilterNames = NativeLibrary.GetTextureFilterNames(); + Setting textureFilterName = premiumSection.getSetting(SettingsFile.KEY_TEXTURE_FILTER_NAME); + sl.add(new StringSingleChoiceSetting(SettingsFile.KEY_TEXTURE_FILTER_NAME, Settings.SECTION_PREMIUM, R.string.texture_filter_name, R.string.texture_filter_description, textureFilterNames, textureFilterNames, "none", textureFilterName)); + } + + private void addGeneralSettings(ArrayList sl) { + mView.getActivity().setTitle(R.string.preferences_general); + + SettingSection rendererSection = mSettings.getSection(Settings.SECTION_RENDERER); + Setting frameLimitEnable = rendererSection.getSetting(SettingsFile.KEY_FRAME_LIMIT_ENABLED); + Setting frameLimitValue = rendererSection.getSetting(SettingsFile.KEY_FRAME_LIMIT); + + sl.add(new CheckBoxSetting(SettingsFile.KEY_FRAME_LIMIT_ENABLED, Settings.SECTION_RENDERER, R.string.frame_limit_enable, R.string.frame_limit_enable_description, true, frameLimitEnable)); + sl.add(new SliderSetting(SettingsFile.KEY_FRAME_LIMIT, Settings.SECTION_RENDERER, R.string.frame_limit_slider, R.string.frame_limit_slider_description, 1, 200, "%", 100, frameLimitValue)); + } + + private void addSystemSettings(ArrayList sl) { + mView.getActivity().setTitle(R.string.preferences_system); + + SettingSection systemSection = mSettings.getSection(Settings.SECTION_SYSTEM); + Setting region = systemSection.getSetting(SettingsFile.KEY_REGION_VALUE); + Setting language = systemSection.getSetting(SettingsFile.KEY_LANGUAGE); + Setting systemClock = systemSection.getSetting(SettingsFile.KEY_INIT_CLOCK); + Setting dateTime = systemSection.getSetting(SettingsFile.KEY_INIT_TIME); + + sl.add(new SingleChoiceSetting(SettingsFile.KEY_REGION_VALUE, Settings.SECTION_SYSTEM, R.string.emulated_region, 0, R.array.regionNames, R.array.regionValues, -1, region)); + sl.add(new SingleChoiceSetting(SettingsFile.KEY_LANGUAGE, Settings.SECTION_SYSTEM, R.string.emulated_language, 0, R.array.languageNames, R.array.languageValues, 1, language)); + sl.add(new SingleChoiceSetting(SettingsFile.KEY_INIT_CLOCK, Settings.SECTION_SYSTEM, R.string.init_clock, R.string.init_clock_description, R.array.systemClockNames, R.array.systemClockValues, 0, systemClock)); + sl.add(new DateTimeSetting(SettingsFile.KEY_INIT_TIME, Settings.SECTION_SYSTEM, R.string.init_time, R.string.init_time_description, "2000-01-01 00:00:01", dateTime)); + } + + private void addCameraSettings(ArrayList sl) { + final Activity activity = mView.getActivity(); + activity.setTitle(R.string.preferences_camera); + + // Get the camera IDs + CameraManager cameraManager = (CameraManager) activity.getSystemService(Context.CAMERA_SERVICE); + ArrayList supportedCameraNameList = new ArrayList<>(); + ArrayList supportedCameraIdList = new ArrayList<>(); + if (cameraManager != null) { + try { + for (String id : cameraManager.getCameraIdList()) { + final CameraCharacteristics characteristics = cameraManager.getCameraCharacteristics(id); + if (Objects.requireNonNull(characteristics.get(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL)) == CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY) { + continue; // Legacy cameras cannot be used with the NDK + } + + supportedCameraIdList.add(id); + + final int facing = Objects.requireNonNull(characteristics.get(CameraCharacteristics.LENS_FACING)); + int stringId = R.string.camera_facing_external; + switch (facing) { + case CameraCharacteristics.LENS_FACING_FRONT: + stringId = R.string.camera_facing_front; + break; + case CameraCharacteristics.LENS_FACING_BACK: + stringId = R.string.camera_facing_back; + break; + case CameraCharacteristics.LENS_FACING_EXTERNAL: + stringId = R.string.camera_facing_external; + break; + } + supportedCameraNameList.add(String.format("%1$s (%2$s)", id, activity.getString(stringId))); + } + } catch (CameraAccessException e) { + Log.error("Couldn't retrieve camera list"); + e.printStackTrace(); + } + } + + // Create the names and values for display + ArrayList cameraDeviceNameList = new ArrayList<>(Arrays.asList(activity.getResources().getStringArray(R.array.cameraDeviceNames))); + cameraDeviceNameList.addAll(supportedCameraNameList); + ArrayList cameraDeviceValueList = new ArrayList<>(Arrays.asList(activity.getResources().getStringArray(R.array.cameraDeviceValues))); + cameraDeviceValueList.addAll(supportedCameraIdList); + + final String[] cameraDeviceNames = cameraDeviceNameList.toArray(new String[]{}); + final String[] cameraDeviceValues = cameraDeviceValueList.toArray(new String[]{}); + + final boolean haveCameraDevices = !supportedCameraIdList.isEmpty(); + + String[] imageSourceNames = activity.getResources().getStringArray(R.array.cameraImageSourceNames); + String[] imageSourceValues = activity.getResources().getStringArray(R.array.cameraImageSourceValues); + if (!haveCameraDevices) { + // Remove the last entry (ndk / Device Camera) + imageSourceNames = Arrays.copyOfRange(imageSourceNames, 0, imageSourceNames.length - 1); + imageSourceValues = Arrays.copyOfRange(imageSourceValues, 0, imageSourceValues.length - 1); + } + + final String defaultImageSource = haveCameraDevices ? "ndk" : "image"; + + SettingSection cameraSection = mSettings.getSection(Settings.SECTION_CAMERA); + + Setting innerCameraImageSource = cameraSection.getSetting(SettingsFile.KEY_CAMERA_INNER_NAME); + Setting innerCameraConfig = asStringSetting(cameraSection.getSetting(SettingsFile.KEY_CAMERA_INNER_CONFIG)); + Setting innerCameraFlip = cameraSection.getSetting(SettingsFile.KEY_CAMERA_INNER_FLIP); + sl.add(new HeaderSetting(null, null, R.string.inner_camera, 0)); + sl.add(new StringSingleChoiceSetting(SettingsFile.KEY_CAMERA_INNER_NAME, Settings.SECTION_CAMERA, R.string.image_source, R.string.image_source_description, imageSourceNames, imageSourceValues, defaultImageSource, innerCameraImageSource)); + if (haveCameraDevices) + sl.add(new StringSingleChoiceSetting(SettingsFile.KEY_CAMERA_INNER_CONFIG, Settings.SECTION_CAMERA, R.string.camera_device, R.string.camera_device_description, cameraDeviceNames, cameraDeviceValues, "_front", innerCameraConfig)); + sl.add(new SingleChoiceSetting(SettingsFile.KEY_CAMERA_INNER_FLIP, Settings.SECTION_CAMERA, R.string.image_flip, 0, R.array.cameraFlipNames, R.array.cameraFlipValues, 0, innerCameraFlip)); + + Setting outerLeftCameraImageSource = cameraSection.getSetting(SettingsFile.KEY_CAMERA_OUTER_LEFT_NAME); + Setting outerLeftCameraConfig = asStringSetting(cameraSection.getSetting(SettingsFile.KEY_CAMERA_OUTER_LEFT_CONFIG)); + Setting outerLeftCameraFlip = cameraSection.getSetting(SettingsFile.KEY_CAMERA_OUTER_LEFT_FLIP); + sl.add(new HeaderSetting(null, null, R.string.outer_left_camera, 0)); + sl.add(new StringSingleChoiceSetting(SettingsFile.KEY_CAMERA_OUTER_LEFT_NAME, Settings.SECTION_CAMERA, R.string.image_source, R.string.image_source_description, imageSourceNames, imageSourceValues, defaultImageSource, outerLeftCameraImageSource)); + if (haveCameraDevices) + sl.add(new StringSingleChoiceSetting(SettingsFile.KEY_CAMERA_OUTER_LEFT_CONFIG, Settings.SECTION_CAMERA, R.string.camera_device, R.string.camera_device_description, cameraDeviceNames, cameraDeviceValues, "_back", outerLeftCameraConfig)); + sl.add(new SingleChoiceSetting(SettingsFile.KEY_CAMERA_OUTER_LEFT_FLIP, Settings.SECTION_CAMERA, R.string.image_flip, 0, R.array.cameraFlipNames, R.array.cameraFlipValues, 0, outerLeftCameraFlip)); + + Setting outerRightCameraImageSource = cameraSection.getSetting(SettingsFile.KEY_CAMERA_OUTER_RIGHT_NAME); + Setting outerRightCameraConfig = asStringSetting(cameraSection.getSetting(SettingsFile.KEY_CAMERA_OUTER_RIGHT_CONFIG)); + Setting outerRightCameraFlip = cameraSection.getSetting(SettingsFile.KEY_CAMERA_OUTER_RIGHT_FLIP); + sl.add(new HeaderSetting(null, null, R.string.outer_right_camera, 0)); + sl.add(new StringSingleChoiceSetting(SettingsFile.KEY_CAMERA_OUTER_RIGHT_NAME, Settings.SECTION_CAMERA, R.string.image_source, R.string.image_source_description, imageSourceNames, imageSourceValues, defaultImageSource, outerRightCameraImageSource)); + if (haveCameraDevices) + sl.add(new StringSingleChoiceSetting(SettingsFile.KEY_CAMERA_OUTER_RIGHT_CONFIG, Settings.SECTION_CAMERA, R.string.camera_device, R.string.camera_device_description, cameraDeviceNames, cameraDeviceValues, "_back", outerRightCameraConfig)); + sl.add(new SingleChoiceSetting(SettingsFile.KEY_CAMERA_OUTER_RIGHT_FLIP, Settings.SECTION_CAMERA, R.string.image_flip, 0, R.array.cameraFlipNames, R.array.cameraFlipValues, 0, outerRightCameraFlip)); + } + + private void addInputSettings(ArrayList sl) { + mView.getActivity().setTitle(R.string.preferences_controls); + + SettingSection controlsSection = mSettings.getSection(Settings.SECTION_CONTROLS); + Setting buttonA = controlsSection.getSetting(SettingsFile.KEY_BUTTON_A); + Setting buttonB = controlsSection.getSetting(SettingsFile.KEY_BUTTON_B); + Setting buttonX = controlsSection.getSetting(SettingsFile.KEY_BUTTON_X); + Setting buttonY = controlsSection.getSetting(SettingsFile.KEY_BUTTON_Y); + Setting buttonSelect = controlsSection.getSetting(SettingsFile.KEY_BUTTON_SELECT); + Setting buttonStart = controlsSection.getSetting(SettingsFile.KEY_BUTTON_START); + Setting circlepadAxisVert = controlsSection.getSetting(SettingsFile.KEY_CIRCLEPAD_AXIS_VERTICAL); + Setting circlepadAxisHoriz = controlsSection.getSetting(SettingsFile.KEY_CIRCLEPAD_AXIS_HORIZONTAL); + Setting cstickAxisVert = controlsSection.getSetting(SettingsFile.KEY_CSTICK_AXIS_VERTICAL); + Setting cstickAxisHoriz = controlsSection.getSetting(SettingsFile.KEY_CSTICK_AXIS_HORIZONTAL); + Setting dpadAxisVert = controlsSection.getSetting(SettingsFile.KEY_DPAD_AXIS_VERTICAL); + Setting dpadAxisHoriz = controlsSection.getSetting(SettingsFile.KEY_DPAD_AXIS_HORIZONTAL); + // Setting buttonUp = controlsSection.getSetting(SettingsFile.KEY_BUTTON_UP); + // Setting buttonDown = controlsSection.getSetting(SettingsFile.KEY_BUTTON_DOWN); + // Setting buttonLeft = controlsSection.getSetting(SettingsFile.KEY_BUTTON_LEFT); + // Setting buttonRight = controlsSection.getSetting(SettingsFile.KEY_BUTTON_RIGHT); + Setting buttonL = controlsSection.getSetting(SettingsFile.KEY_BUTTON_L); + Setting buttonR = controlsSection.getSetting(SettingsFile.KEY_BUTTON_R); + Setting buttonZL = controlsSection.getSetting(SettingsFile.KEY_BUTTON_ZL); + Setting buttonZR = controlsSection.getSetting(SettingsFile.KEY_BUTTON_ZR); + + sl.add(new HeaderSetting(null, null, R.string.generic_buttons, 0)); + sl.add(new InputBindingSetting(SettingsFile.KEY_BUTTON_A, Settings.SECTION_CONTROLS, R.string.button_a, buttonA)); + sl.add(new InputBindingSetting(SettingsFile.KEY_BUTTON_B, Settings.SECTION_CONTROLS, R.string.button_b, buttonB)); + sl.add(new InputBindingSetting(SettingsFile.KEY_BUTTON_X, Settings.SECTION_CONTROLS, R.string.button_x, buttonX)); + sl.add(new InputBindingSetting(SettingsFile.KEY_BUTTON_Y, Settings.SECTION_CONTROLS, R.string.button_y, buttonY)); + sl.add(new InputBindingSetting(SettingsFile.KEY_BUTTON_SELECT, Settings.SECTION_CONTROLS, R.string.button_select, buttonSelect)); + sl.add(new InputBindingSetting(SettingsFile.KEY_BUTTON_START, Settings.SECTION_CONTROLS, R.string.button_start, buttonStart)); + + sl.add(new HeaderSetting(null, null, R.string.controller_circlepad, 0)); + sl.add(new InputBindingSetting(SettingsFile.KEY_CIRCLEPAD_AXIS_VERTICAL, Settings.SECTION_CONTROLS, R.string.controller_axis_vertical, circlepadAxisVert)); + sl.add(new InputBindingSetting(SettingsFile.KEY_CIRCLEPAD_AXIS_HORIZONTAL, Settings.SECTION_CONTROLS, R.string.controller_axis_horizontal, circlepadAxisHoriz)); + + sl.add(new HeaderSetting(null, null, R.string.controller_c, 0)); + sl.add(new InputBindingSetting(SettingsFile.KEY_CSTICK_AXIS_VERTICAL, Settings.SECTION_CONTROLS, R.string.controller_axis_vertical, cstickAxisVert)); + sl.add(new InputBindingSetting(SettingsFile.KEY_CSTICK_AXIS_HORIZONTAL, Settings.SECTION_CONTROLS, R.string.controller_axis_horizontal, cstickAxisHoriz)); + + sl.add(new HeaderSetting(null, null, R.string.controller_dpad, 0)); + sl.add(new InputBindingSetting(SettingsFile.KEY_DPAD_AXIS_VERTICAL, Settings.SECTION_CONTROLS, R.string.controller_axis_vertical, dpadAxisVert)); + sl.add(new InputBindingSetting(SettingsFile.KEY_DPAD_AXIS_HORIZONTAL, Settings.SECTION_CONTROLS, R.string.controller_axis_horizontal, dpadAxisHoriz)); + + // TODO(bunnei): Figure out what to do with these. Configuring is functional, but removing for MVP because they are confusing. + // sl.add(new InputBindingSetting(SettingsFile.KEY_BUTTON_UP, Settings.SECTION_CONTROLS, R.string.generic_up, buttonUp)); + // sl.add(new InputBindingSetting(SettingsFile.KEY_BUTTON_DOWN, Settings.SECTION_CONTROLS, R.string.generic_down, buttonDown)); + // sl.add(new InputBindingSetting(SettingsFile.KEY_BUTTON_LEFT, Settings.SECTION_CONTROLS, R.string.generic_left, buttonLeft)); + // sl.add(new InputBindingSetting(SettingsFile.KEY_BUTTON_RIGHT, Settings.SECTION_CONTROLS, R.string.generic_right, buttonRight)); + + sl.add(new HeaderSetting(null, null, R.string.controller_triggers, 0)); + sl.add(new InputBindingSetting(SettingsFile.KEY_BUTTON_L, Settings.SECTION_CONTROLS, R.string.button_l, buttonL)); + sl.add(new InputBindingSetting(SettingsFile.KEY_BUTTON_R, Settings.SECTION_CONTROLS, R.string.button_r, buttonR)); + sl.add(new InputBindingSetting(SettingsFile.KEY_BUTTON_ZL, Settings.SECTION_CONTROLS, R.string.button_zl, buttonZL)); + sl.add(new InputBindingSetting(SettingsFile.KEY_BUTTON_ZR, Settings.SECTION_CONTROLS, R.string.button_zr, buttonZR)); + } + + private void addGraphicsSettings(ArrayList sl) { + mView.getActivity().setTitle(R.string.preferences_graphics); + + SettingSection rendererSection = mSettings.getSection(Settings.SECTION_RENDERER); + Setting resolutionFactor = rendererSection.getSetting(SettingsFile.KEY_RESOLUTION_FACTOR); + Setting filterMode = rendererSection.getSetting(SettingsFile.KEY_FILTER_MODE); + Setting shadersAccurateMul = rendererSection.getSetting(SettingsFile.KEY_SHADERS_ACCURATE_MUL); + Setting render3dMode = rendererSection.getSetting(SettingsFile.KEY_RENDER_3D); + Setting factor3d = rendererSection.getSetting(SettingsFile.KEY_FACTOR_3D); + Setting useDiskShaderCache = rendererSection.getSetting(SettingsFile.KEY_USE_DISK_SHADER_CACHE); + + SettingSection layoutSection = mSettings.getSection(Settings.SECTION_LAYOUT); + Setting cardboardScreenSize = layoutSection.getSetting(SettingsFile.KEY_CARDBOARD_SCREEN_SIZE); + Setting cardboardXShift = layoutSection.getSetting(SettingsFile.KEY_CARDBOARD_X_SHIFT); + Setting cardboardYShift = layoutSection.getSetting(SettingsFile.KEY_CARDBOARD_Y_SHIFT); + + sl.add(new HeaderSetting(null, null, R.string.renderer, 0)); + sl.add(new SliderSetting(SettingsFile.KEY_RESOLUTION_FACTOR, Settings.SECTION_RENDERER, R.string.internal_resolution, R.string.internal_resolution_description, 1, 4, "x", 1, resolutionFactor)); + sl.add(new CheckBoxSetting(SettingsFile.KEY_FILTER_MODE, Settings.SECTION_RENDERER, R.string.linear_filtering, R.string.linear_filtering_description, true, filterMode)); + sl.add(new CheckBoxSetting(SettingsFile.KEY_SHADERS_ACCURATE_MUL, Settings.SECTION_RENDERER, R.string.shaders_accurate_mul, R.string.shaders_accurate_mul_description, false, shadersAccurateMul)); + sl.add(new CheckBoxSetting(SettingsFile.KEY_USE_DISK_SHADER_CACHE, Settings.SECTION_RENDERER, R.string.use_disk_shader_cache, R.string.use_disk_shader_cache_description, true, useDiskShaderCache)); + + sl.add(new HeaderSetting(null, null, R.string.stereoscopy, 0)); + sl.add(new SingleChoiceSetting(SettingsFile.KEY_RENDER_3D, Settings.SECTION_RENDERER, R.string.render3d, 0, R.array.render3dModes, R.array.render3dValues, 0, render3dMode)); + sl.add(new SliderSetting(SettingsFile.KEY_FACTOR_3D, Settings.SECTION_RENDERER, R.string.factor3d, R.string.factor3d_description, 0, 100, "%", 0, factor3d)); + + sl.add(new HeaderSetting(null, null, R.string.cardboard_vr, 0)); + sl.add(new SliderSetting(SettingsFile.KEY_CARDBOARD_SCREEN_SIZE, Settings.SECTION_LAYOUT, R.string.cardboard_screen_size, R.string.cardboard_screen_size_description, 30, 100, "%", 85, cardboardScreenSize)); + sl.add(new SliderSetting(SettingsFile.KEY_CARDBOARD_X_SHIFT, Settings.SECTION_LAYOUT, R.string.cardboard_x_shift, R.string.cardboard_x_shift_description, -100, 100, "%", 0, cardboardXShift)); + sl.add(new SliderSetting(SettingsFile.KEY_CARDBOARD_Y_SHIFT, Settings.SECTION_LAYOUT, R.string.cardboard_y_shift, R.string.cardboard_y_shift_description, -100, 100, "%", 0, cardboardYShift)); + } + + private void addAudioSettings(ArrayList sl) { + mView.getActivity().setTitle(R.string.preferences_audio); + + SettingSection audioSection = mSettings.getSection(Settings.SECTION_AUDIO); + Setting audioStretch = audioSection.getSetting(SettingsFile.KEY_ENABLE_AUDIO_STRETCHING); + Setting micInputType = audioSection.getSetting(SettingsFile.KEY_MIC_INPUT_TYPE); + + sl.add(new CheckBoxSetting(SettingsFile.KEY_ENABLE_AUDIO_STRETCHING, Settings.SECTION_AUDIO, R.string.audio_stretch, R.string.audio_stretch_description, true, audioStretch)); + sl.add(new SingleChoiceSetting(SettingsFile.KEY_MIC_INPUT_TYPE, Settings.SECTION_AUDIO, R.string.audio_input_type, 0, R.array.audioInputTypeNames, R.array.audioInputTypeValues, 1, micInputType)); + } + + private void addDebugSettings(ArrayList sl) { + mView.getActivity().setTitle(R.string.preferences_debug); + + SettingSection coreSection = mSettings.getSection(Settings.SECTION_CORE); + SettingSection rendererSection = mSettings.getSection(Settings.SECTION_RENDERER); + Setting useCpuJit = coreSection.getSetting(SettingsFile.KEY_CPU_JIT); + Setting hardwareRenderer = rendererSection.getSetting(SettingsFile.KEY_HW_RENDERER); + Setting hardwareShader = rendererSection.getSetting(SettingsFile.KEY_HW_SHADER); + Setting vsyncEnable = rendererSection.getSetting(SettingsFile.KEY_USE_VSYNC); + + sl.add(new HeaderSetting(null, null, R.string.debug_warning, 0)); + sl.add(new CheckBoxSetting(SettingsFile.KEY_CPU_JIT, Settings.SECTION_CORE, R.string.cpu_jit, R.string.cpu_jit_description, true, useCpuJit, true, mView)); + sl.add(new CheckBoxSetting(SettingsFile.KEY_HW_RENDERER, Settings.SECTION_RENDERER, R.string.hw_renderer, R.string.hw_renderer_description, true, hardwareRenderer, true, mView)); + sl.add(new CheckBoxSetting(SettingsFile.KEY_HW_SHADER, Settings.SECTION_RENDERER, R.string.hw_shaders, R.string.hw_shaders_description, true, hardwareShader, true, mView)); + sl.add(new CheckBoxSetting(SettingsFile.KEY_USE_VSYNC, Settings.SECTION_RENDERER, R.string.vsync, R.string.vsync_description, true, vsyncEnable)); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentView.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentView.java new file mode 100644 index 000000000..c36eb55a7 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentView.java @@ -0,0 +1,78 @@ +package org.citra.citra_emu.features.settings.ui; + +import androidx.fragment.app.FragmentActivity; + +import org.citra.citra_emu.features.settings.model.Setting; +import org.citra.citra_emu.features.settings.model.Settings; +import org.citra.citra_emu.features.settings.model.view.SettingsItem; + +import java.util.ArrayList; + +/** + * Abstraction for a screen showing a list of settings. Instances of + * this type of view will each display a layer of the setting hierarchy. + */ +public interface SettingsFragmentView { + /** + * Called by the containing Activity to notify the Fragment that an + * asynchronous load operation completed. + * + * @param settings The (possibly null) result of the ini load operation. + */ + void onSettingsFileLoaded(Settings settings); + + /** + * Pass a settings HashMap to the containing activity, so that it can + * share the HashMap with other SettingsFragments; useful so that rotations + * do not require an additional load operation. + * + * @param settings An ArrayList containing all the settings HashMaps. + */ + void passSettingsToActivity(Settings settings); + + /** + * 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 + */ + void showSettingsList(ArrayList settingsList); + + /** + * Called by the containing Activity when an asynchronous load operation fails. + * Instructs the Fragment to load the settings screen with defaults selected. + */ + void loadDefaultSettings(); + + /** + * @return The Fragment's containing activity. + */ + FragmentActivity getActivity(); + + /** + * 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. + */ + void loadSubMenu(String menuKey); + + /** + * 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. + */ + void showToastMessage(String message, boolean is_long); + + /** + * Have the fragment add a setting to the HashMap. + * + * @param setting The (possibly previously missing) new setting. + */ + void putSetting(Setting setting); + + /** + * Have the fragment tell the containing Activity that a setting was modified. + */ + void onSettingChanged(); +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFrameLayout.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFrameLayout.java new file mode 100644 index 000000000..67bde5709 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFrameLayout.java @@ -0,0 +1,48 @@ +package org.citra.citra_emu.features.settings.ui; + +import android.content.Context; +import android.util.AttributeSet; +import android.widget.FrameLayout; + +/** + * FrameLayout subclass with few Properties added to simplify animations. + * Don't remove the methods appearing as unused, in order not to break the menu animations + */ +public final class SettingsFrameLayout extends FrameLayout { + private float mVisibleness = 1.0f; + + public SettingsFrameLayout(Context context) { + super(context); + } + + public SettingsFrameLayout(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public SettingsFrameLayout(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public SettingsFrameLayout(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + public float getYFraction() { + return getY() / getHeight(); + } + + public void setYFraction(float yFraction) { + final int height = getHeight(); + setY((height > 0) ? (yFraction * height) : -9999); + } + + public float getVisibleness() { + return mVisibleness; + } + + public void setVisibleness(float visibleness) { + setScaleX(visibleness); + setScaleY(visibleness); + setAlpha(visibleness); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/CheckBoxSettingViewHolder.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/CheckBoxSettingViewHolder.java new file mode 100644 index 000000000..d914f7d0b --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/CheckBoxSettingViewHolder.java @@ -0,0 +1,54 @@ +package org.citra.citra_emu.features.settings.ui.viewholder; + +import android.view.View; +import android.widget.CheckBox; +import android.widget.TextView; + +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.settings.model.view.CheckBoxSetting; +import org.citra.citra_emu.features.settings.model.view.SettingsItem; +import org.citra.citra_emu.features.settings.ui.SettingsAdapter; + +public final class CheckBoxSettingViewHolder extends SettingViewHolder { + private CheckBoxSetting mItem; + + private TextView mTextSettingName; + private TextView mTextSettingDescription; + + private CheckBox mCheckbox; + + public CheckBoxSettingViewHolder(View itemView, SettingsAdapter adapter) { + super(itemView, adapter); + } + + @Override + protected void findViews(View root) { + mTextSettingName = root.findViewById(R.id.text_setting_name); + mTextSettingDescription = root.findViewById(R.id.text_setting_description); + mCheckbox = root.findViewById(R.id.checkbox); + } + + @Override + public void bind(SettingsItem item) { + mItem = (CheckBoxSetting) item; + + mTextSettingName.setText(item.getNameId()); + + if (item.getDescriptionId() > 0) { + mTextSettingDescription.setText(item.getDescriptionId()); + mTextSettingDescription.setVisibility(View.VISIBLE); + } else { + mTextSettingDescription.setText(""); + mTextSettingDescription.setVisibility(View.GONE); + } + + mCheckbox.setChecked(mItem.isChecked()); + } + + @Override + public void onClick(View clicked) { + mCheckbox.toggle(); + + getAdapter().onBooleanClick(mItem, getAdapterPosition(), mCheckbox.isChecked()); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/DateTimeViewHolder.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/DateTimeViewHolder.java new file mode 100644 index 000000000..09ea93010 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/DateTimeViewHolder.java @@ -0,0 +1,47 @@ +package org.citra.citra_emu.features.settings.ui.viewholder; + +import android.view.View; +import android.widget.TextView; + +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.settings.model.view.DateTimeSetting; +import org.citra.citra_emu.features.settings.model.view.SettingsItem; +import org.citra.citra_emu.features.settings.ui.SettingsAdapter; +import org.citra.citra_emu.utils.Log; + +public final class DateTimeViewHolder extends SettingViewHolder { + private DateTimeSetting mItem; + + private TextView mTextSettingName; + private TextView mTextSettingDescription; + + public DateTimeViewHolder(View itemView, SettingsAdapter adapter) { + super(itemView, adapter); + } + + @Override + protected void findViews(View root) { + mTextSettingName = root.findViewById(R.id.text_setting_name); + Log.error("test " + mTextSettingName); + mTextSettingDescription = root.findViewById(R.id.text_setting_description); + Log.error("test " + mTextSettingDescription); + } + + @Override + public void bind(SettingsItem item) { + mItem = (DateTimeSetting) item; + mTextSettingName.setText(item.getNameId()); + + if (item.getDescriptionId() > 0) { + mTextSettingDescription.setText(item.getDescriptionId()); + mTextSettingDescription.setVisibility(View.VISIBLE); + } else { + mTextSettingDescription.setVisibility(View.GONE); + } + } + + @Override + public void onClick(View clicked) { + getAdapter().onDateTimeClick(mItem, getAdapterPosition()); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/HeaderViewHolder.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/HeaderViewHolder.java new file mode 100644 index 000000000..baf80ed76 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/HeaderViewHolder.java @@ -0,0 +1,32 @@ +package org.citra.citra_emu.features.settings.ui.viewholder; + +import android.view.View; +import android.widget.TextView; + +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.settings.model.view.SettingsItem; +import org.citra.citra_emu.features.settings.ui.SettingsAdapter; + +public final class HeaderViewHolder extends SettingViewHolder { + private TextView mHeaderName; + + public HeaderViewHolder(View itemView, SettingsAdapter adapter) { + super(itemView, adapter); + itemView.setOnClickListener(null); + } + + @Override + protected void findViews(View root) { + mHeaderName = root.findViewById(R.id.text_header_name); + } + + @Override + public void bind(SettingsItem item) { + mHeaderName.setText(item.getNameId()); + } + + @Override + public void onClick(View clicked) { + // no-op + } +} \ No newline at end of file diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/InputBindingSettingViewHolder.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/InputBindingSettingViewHolder.java new file mode 100644 index 000000000..7d95c250a --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/InputBindingSettingViewHolder.java @@ -0,0 +1,55 @@ +package org.citra.citra_emu.features.settings.ui.viewholder; + +import android.content.Context; +import android.content.SharedPreferences; +import android.preference.PreferenceManager; +import android.view.View; +import android.widget.TextView; + +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.settings.model.view.InputBindingSetting; +import org.citra.citra_emu.features.settings.model.view.SettingsItem; +import org.citra.citra_emu.features.settings.ui.SettingsAdapter; + +public final class InputBindingSettingViewHolder extends SettingViewHolder { + private InputBindingSetting mItem; + + private TextView mTextSettingName; + private TextView mTextSettingDescription; + + private Context mContext; + + public InputBindingSettingViewHolder(View itemView, SettingsAdapter adapter, Context context) { + super(itemView, adapter); + + mContext = context; + } + + @Override + protected void findViews(View root) { + mTextSettingName = root.findViewById(R.id.text_setting_name); + mTextSettingDescription = root.findViewById(R.id.text_setting_description); + } + + @Override + public void bind(SettingsItem item) { + SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(mContext); + + mItem = (InputBindingSetting) item; + + mTextSettingName.setText(item.getNameId()); + + String key = sharedPreferences.getString(mItem.getKey(), ""); + if (key != null && !key.isEmpty()) { + mTextSettingDescription.setText(key); + mTextSettingDescription.setVisibility(View.VISIBLE); + } else { + mTextSettingDescription.setVisibility(View.GONE); + } + } + + @Override + public void onClick(View clicked) { + getAdapter().onInputBindingClick(mItem, getAdapterPosition()); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/PremiumViewHolder.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/PremiumViewHolder.java new file mode 100644 index 000000000..be0853ff0 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/PremiumViewHolder.java @@ -0,0 +1,57 @@ +package org.citra.citra_emu.features.settings.ui.viewholder; + +import android.view.View; +import android.widget.TextView; + +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.settings.model.view.SettingsItem; +import org.citra.citra_emu.features.settings.ui.SettingsAdapter; +import org.citra.citra_emu.features.settings.ui.SettingsFragmentView; +import org.citra.citra_emu.ui.main.MainActivity; + +public final class PremiumViewHolder extends SettingViewHolder { + private TextView mHeaderName; + private TextView mTextDescription; + private SettingsFragmentView mView; + + public PremiumViewHolder(View itemView, SettingsAdapter adapter, SettingsFragmentView view) { + super(itemView, adapter); + mView = view; + itemView.setOnClickListener(this); + } + + @Override + protected void findViews(View root) { + mHeaderName = root.findViewById(R.id.text_setting_name); + mTextDescription = root.findViewById(R.id.text_setting_description); + } + + @Override + public void bind(SettingsItem item) { + updateText(); + } + + @Override + public void onClick(View clicked) { + if (MainActivity.isPremiumActive()) { + return; + } + + // Invoke billing flow if Premium is not already active, then refresh the UI to indicate + // the purchase has completed. + MainActivity.invokePremiumBilling(() -> updateText()); + } + + /** + * Update the text shown to the user, based on whether Premium is active + */ + private void updateText() { + if (MainActivity.isPremiumActive()) { + mHeaderName.setText(R.string.premium_settings_welcome); + mTextDescription.setText(R.string.premium_settings_welcome_description); + } else { + mHeaderName.setText(R.string.premium_settings_upsell); + mTextDescription.setText(R.string.premium_settings_upsell_description); + } + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SettingViewHolder.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SettingViewHolder.java new file mode 100644 index 000000000..2643ea121 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SettingViewHolder.java @@ -0,0 +1,49 @@ +package org.citra.citra_emu.features.settings.ui.viewholder; + +import android.view.View; + +import androidx.recyclerview.widget.RecyclerView; + +import org.citra.citra_emu.features.settings.model.view.SettingsItem; +import org.citra.citra_emu.features.settings.ui.SettingsAdapter; + +public abstract class SettingViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener { + private SettingsAdapter mAdapter; + + public SettingViewHolder(View itemView, SettingsAdapter adapter) { + super(itemView); + + mAdapter = adapter; + + itemView.setOnClickListener(this); + + findViews(itemView); + } + + protected SettingsAdapter getAdapter() { + return mAdapter; + } + + /** + * Gets handles to all this ViewHolder's child views using their XML-defined identifiers. + * + * @param root The newly inflated top-level view. + */ + protected abstract void findViews(View root); + + /** + * 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. + */ + public abstract void bind(SettingsItem item); + + /** + * 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. + */ + public abstract void onClick(View clicked); +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SingleChoiceViewHolder.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SingleChoiceViewHolder.java new file mode 100644 index 000000000..a175af9f8 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SingleChoiceViewHolder.java @@ -0,0 +1,76 @@ +package org.citra.citra_emu.features.settings.ui.viewholder; + +import android.content.res.Resources; +import android.view.View; +import android.widget.TextView; + +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.settings.model.view.PremiumSingleChoiceSetting; +import org.citra.citra_emu.features.settings.model.view.SettingsItem; +import org.citra.citra_emu.features.settings.model.view.SingleChoiceSetting; +import org.citra.citra_emu.features.settings.model.view.StringSingleChoiceSetting; +import org.citra.citra_emu.features.settings.ui.SettingsAdapter; + +public final class SingleChoiceViewHolder extends SettingViewHolder { + private SettingsItem mItem; + + private TextView mTextSettingName; + private TextView mTextSettingDescription; + + public SingleChoiceViewHolder(View itemView, SettingsAdapter adapter) { + super(itemView, adapter); + } + + @Override + protected void findViews(View root) { + mTextSettingName = root.findViewById(R.id.text_setting_name); + mTextSettingDescription = root.findViewById(R.id.text_setting_description); + } + + @Override + public void bind(SettingsItem item) { + mItem = item; + + mTextSettingName.setText(item.getNameId()); + mTextSettingDescription.setVisibility(View.VISIBLE); + if (item.getDescriptionId() > 0) { + mTextSettingDescription.setText(item.getDescriptionId()); + } else if (item instanceof SingleChoiceSetting) { + SingleChoiceSetting setting = (SingleChoiceSetting) item; + int selected = setting.getSelectedValue(); + Resources resMgr = mTextSettingDescription.getContext().getResources(); + String[] choices = resMgr.getStringArray(setting.getChoicesId()); + int[] values = resMgr.getIntArray(setting.getValuesId()); + for (int i = 0; i < values.length; ++i) { + if (values[i] == selected) { + mTextSettingDescription.setText(choices[i]); + } + } + } else if (item instanceof PremiumSingleChoiceSetting) { + PremiumSingleChoiceSetting setting = (PremiumSingleChoiceSetting) item; + int selected = setting.getSelectedValue(); + Resources resMgr = mTextSettingDescription.getContext().getResources(); + String[] choices = resMgr.getStringArray(setting.getChoicesId()); + int[] values = resMgr.getIntArray(setting.getValuesId()); + for (int i = 0; i < values.length; ++i) { + if (values[i] == selected) { + mTextSettingDescription.setText(choices[i]); + } + } + } else { + mTextSettingDescription.setVisibility(View.GONE); + } + } + + @Override + public void onClick(View clicked) { + int position = getAdapterPosition(); + if (mItem instanceof SingleChoiceSetting) { + getAdapter().onSingleChoiceClick((SingleChoiceSetting) mItem, position); + } else if (mItem instanceof PremiumSingleChoiceSetting) { + getAdapter().onSingleChoiceClick((PremiumSingleChoiceSetting) mItem, position); + } else if (mItem instanceof StringSingleChoiceSetting) { + getAdapter().onStringSingleChoiceClick((StringSingleChoiceSetting) mItem, position); + } + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SliderViewHolder.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SliderViewHolder.java new file mode 100644 index 000000000..ce503bc54 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SliderViewHolder.java @@ -0,0 +1,46 @@ +package org.citra.citra_emu.features.settings.ui.viewholder; + +import android.view.View; +import android.widget.TextView; + +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.settings.model.view.SettingsItem; +import org.citra.citra_emu.features.settings.model.view.SliderSetting; +import org.citra.citra_emu.features.settings.ui.SettingsAdapter; + +public final class SliderViewHolder extends SettingViewHolder { + private SliderSetting mItem; + + private TextView mTextSettingName; + private TextView mTextSettingDescription; + + public SliderViewHolder(View itemView, SettingsAdapter adapter) { + super(itemView, adapter); + } + + @Override + protected void findViews(View root) { + mTextSettingName = root.findViewById(R.id.text_setting_name); + mTextSettingDescription = root.findViewById(R.id.text_setting_description); + } + + @Override + public void bind(SettingsItem item) { + mItem = (SliderSetting) item; + + mTextSettingName.setText(item.getNameId()); + + if (item.getDescriptionId() > 0) { + mTextSettingDescription.setText(item.getDescriptionId()); + mTextSettingDescription.setVisibility(View.VISIBLE); + } else { + mTextSettingDescription.setVisibility(View.GONE); + } + } + + @Override + public void onClick(View clicked) { + getAdapter().onSliderClick(mItem, getAdapterPosition()); + } +} + diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SubmenuViewHolder.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SubmenuViewHolder.java new file mode 100644 index 000000000..cb8c3e92a --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SubmenuViewHolder.java @@ -0,0 +1,45 @@ +package org.citra.citra_emu.features.settings.ui.viewholder; + +import android.view.View; +import android.widget.TextView; + +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.settings.model.view.SettingsItem; +import org.citra.citra_emu.features.settings.model.view.SubmenuSetting; +import org.citra.citra_emu.features.settings.ui.SettingsAdapter; + +public final class SubmenuViewHolder extends SettingViewHolder { + private SubmenuSetting mItem; + + private TextView mTextSettingName; + private TextView mTextSettingDescription; + + public SubmenuViewHolder(View itemView, SettingsAdapter adapter) { + super(itemView, adapter); + } + + @Override + protected void findViews(View root) { + mTextSettingName = root.findViewById(R.id.text_setting_name); + mTextSettingDescription = root.findViewById(R.id.text_setting_description); + } + + @Override + public void bind(SettingsItem item) { + mItem = (SubmenuSetting) item; + + mTextSettingName.setText(item.getNameId()); + + if (item.getDescriptionId() > 0) { + mTextSettingDescription.setText(item.getDescriptionId()); + mTextSettingDescription.setVisibility(View.VISIBLE); + } else { + mTextSettingDescription.setVisibility(View.GONE); + } + } + + @Override + public void onClick(View clicked) { + getAdapter().onSubmenuClick(mItem); + } +} \ No newline at end of file diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/utils/SettingsFile.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/utils/SettingsFile.java new file mode 100644 index 000000000..5403cd7e4 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/utils/SettingsFile.java @@ -0,0 +1,337 @@ +package org.citra.citra_emu.features.settings.utils; + +import androidx.annotation.NonNull; + +import org.citra.citra_emu.CitraApplication; +import org.citra.citra_emu.NativeLibrary; +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.settings.model.FloatSetting; +import org.citra.citra_emu.features.settings.model.IntSetting; +import org.citra.citra_emu.features.settings.model.Setting; +import org.citra.citra_emu.features.settings.model.SettingSection; +import org.citra.citra_emu.features.settings.model.Settings; +import org.citra.citra_emu.features.settings.model.StringSetting; +import org.citra.citra_emu.features.settings.ui.SettingsActivityView; +import org.citra.citra_emu.utils.BiMap; +import org.citra.citra_emu.utils.DirectoryInitialization; +import org.citra.citra_emu.utils.Log; +import org.ini4j.Wini; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileReader; +import java.io.IOException; +import java.util.HashMap; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; + +/** + * Contains static methods for interacting with .ini files in which settings are stored. + */ +public final class SettingsFile { + public static final String FILE_NAME_CONFIG = "config"; + + public static final String KEY_CPU_JIT = "use_cpu_jit"; + + public static final String KEY_DESIGN = "design"; + + public static final String KEY_PREMIUM = "premium"; + + public static final String KEY_HW_RENDERER = "use_hw_renderer"; + public static final String KEY_HW_SHADER = "use_hw_shader"; + public static final String KEY_SHADERS_ACCURATE_MUL = "shaders_accurate_mul"; + public static final String KEY_USE_SHADER_JIT = "use_shader_jit"; + public static final String KEY_USE_DISK_SHADER_CACHE = "use_disk_shader_cache"; + public static final String KEY_USE_VSYNC = "use_vsync_new"; + public static final String KEY_RESOLUTION_FACTOR = "resolution_factor"; + public static final String KEY_FRAME_LIMIT_ENABLED = "use_frame_limit"; + public static final String KEY_FRAME_LIMIT = "frame_limit"; + public static final String KEY_BACKGROUND_RED = "bg_red"; + public static final String KEY_BACKGROUND_BLUE = "bg_blue"; + public static final String KEY_BACKGROUND_GREEN = "bg_green"; + public static final String KEY_RENDER_3D = "render_3d"; + public static final String KEY_FACTOR_3D = "factor_3d"; + public static final String KEY_PP_SHADER_NAME = "pp_shader_name"; + public static final String KEY_FILTER_MODE = "filter_mode"; + public static final String KEY_TEXTURE_FILTER_NAME = "texture_filter_name"; + public static final String KEY_USE_ASYNCHRONOUS_GPU_EMULATION = "use_asynchronous_gpu_emulation"; + + public static final String KEY_LAYOUT_OPTION = "layout_option"; + public static final String KEY_SWAP_SCREEN = "swap_screen"; + public static final String KEY_CARDBOARD_SCREEN_SIZE = "cardboard_screen_size"; + public static final String KEY_CARDBOARD_X_SHIFT = "cardboard_x_shift"; + public static final String KEY_CARDBOARD_Y_SHIFT = "cardboard_y_shift"; + + public static final String KEY_AUDIO_OUTPUT_ENGINE = "output_engine"; + public static final String KEY_ENABLE_AUDIO_STRETCHING = "enable_audio_stretching"; + public static final String KEY_VOLUME = "volume"; + public static final String KEY_MIC_INPUT_TYPE = "mic_input_type"; + + public static final String KEY_USE_VIRTUAL_SD = "use_virtual_sd"; + + public static final String KEY_IS_NEW_3DS = "is_new_3ds"; + public static final String KEY_REGION_VALUE = "region_value"; + public static final String KEY_LANGUAGE = "language"; + + public static final String KEY_INIT_CLOCK = "init_clock"; + public static final String KEY_INIT_TIME = "init_time"; + + public static final String KEY_BUTTON_A = "button_a"; + public static final String KEY_BUTTON_B = "button_b"; + public static final String KEY_BUTTON_X = "button_x"; + public static final String KEY_BUTTON_Y = "button_y"; + public static final String KEY_BUTTON_SELECT = "button_select"; + public static final String KEY_BUTTON_START = "button_start"; + public static final String KEY_BUTTON_UP = "button_up"; + public static final String KEY_BUTTON_DOWN = "button_down"; + public static final String KEY_BUTTON_LEFT = "button_left"; + public static final String KEY_BUTTON_RIGHT = "button_right"; + public static final String KEY_BUTTON_L = "button_l"; + public static final String KEY_BUTTON_R = "button_r"; + public static final String KEY_BUTTON_ZL = "button_zl"; + public static final String KEY_BUTTON_ZR = "button_zr"; + public static final String KEY_CIRCLEPAD_AXIS_VERTICAL = "circlepad_axis_vertical"; + public static final String KEY_CIRCLEPAD_AXIS_HORIZONTAL = "circlepad_axis_horizontal"; + public static final String KEY_CSTICK_AXIS_VERTICAL = "cstick_axis_vertical"; + public static final String KEY_CSTICK_AXIS_HORIZONTAL = "cstick_axis_horizontal"; + public static final String KEY_DPAD_AXIS_VERTICAL = "dpad_axis_vertical"; + public static final String KEY_DPAD_AXIS_HORIZONTAL = "dpad_axis_horizontal"; + public static final String KEY_CIRCLEPAD_UP = "circlepad_up"; + public static final String KEY_CIRCLEPAD_DOWN = "circlepad_down"; + public static final String KEY_CIRCLEPAD_LEFT = "circlepad_left"; + public static final String KEY_CIRCLEPAD_RIGHT = "circlepad_right"; + public static final String KEY_CSTICK_UP = "cstick_up"; + public static final String KEY_CSTICK_DOWN = "cstick_down"; + public static final String KEY_CSTICK_LEFT = "cstick_left"; + public static final String KEY_CSTICK_RIGHT = "cstick_right"; + + public static final String KEY_CAMERA_OUTER_RIGHT_NAME = "camera_outer_right_name"; + public static final String KEY_CAMERA_OUTER_RIGHT_CONFIG = "camera_outer_right_config"; + public static final String KEY_CAMERA_OUTER_RIGHT_FLIP = "camera_outer_right_flip"; + public static final String KEY_CAMERA_OUTER_LEFT_NAME = "camera_outer_left_name"; + public static final String KEY_CAMERA_OUTER_LEFT_CONFIG = "camera_outer_left_config"; + public static final String KEY_CAMERA_OUTER_LEFT_FLIP = "camera_outer_left_flip"; + public static final String KEY_CAMERA_INNER_NAME = "camera_inner_name"; + public static final String KEY_CAMERA_INNER_CONFIG = "camera_inner_config"; + public static final String KEY_CAMERA_INNER_FLIP = "camera_inner_flip"; + + public static final String KEY_LOG_FILTER = "log_filter"; + + private static BiMap sectionsMap = new BiMap<>(); + + static { + //TODO: Add members to sectionsMap when game-specific settings are added + } + + + private SettingsFile() { + } + + /** + * 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. + */ + static HashMap readFile(final File ini, boolean isCustomGame, SettingsActivityView view) { + HashMap sections = new Settings.SettingsSectionMap(); + + BufferedReader reader = null; + + try { + reader = new BufferedReader(new FileReader(ini)); + + SettingSection current = null; + for (String line; (line = reader.readLine()) != null; ) { + if (line.startsWith("[") && line.endsWith("]")) { + current = sectionFromLine(line, isCustomGame); + sections.put(current.getName(), current); + } else if ((current != null)) { + Setting setting = settingFromLine(current, line); + if (setting != null) { + current.putSetting(setting); + } + } + } + } catch (FileNotFoundException e) { + Log.error("[SettingsFile] File not found: " + ini.getAbsolutePath() + e.getMessage()); + if (view != null) + view.onSettingsFileNotFound(); + } catch (IOException e) { + Log.error("[SettingsFile] Error reading from: " + ini.getAbsolutePath() + e.getMessage()); + if (view != null) + view.onSettingsFileNotFound(); + } finally { + if (reader != null) { + try { + reader.close(); + } catch (IOException e) { + Log.error("[SettingsFile] Error closing: " + ini.getAbsolutePath() + e.getMessage()); + } + } + } + + return sections; + } + + public static HashMap readFile(final String fileName, SettingsActivityView view) { + return readFile(getSettingsFile(fileName), false, view); + } + + /** + * Reads a given .ini file from disk and returns it as a HashMap of SettingSections, themselves + * effectively a HashMap of key/value settings. If unsuccessful, outputs an error telling why it + * failed. + * + * @param gameId the id of the game to load it's settings. + * @param view The current view. + */ + public static HashMap readCustomGameSettings(final String gameId, SettingsActivityView view) { + 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. + */ + public static void saveFile(final String fileName, TreeMap sections, + SettingsActivityView view) { + File ini = getSettingsFile(fileName); + + try { + Wini writer = new Wini(ini); + + Set keySet = sections.keySet(); + for (String key : keySet) { + SettingSection section = sections.get(key); + writeSection(writer, section); + } + writer.store(); + } catch (IOException e) { + Log.error("[SettingsFile] File not found: " + fileName + ".ini: " + e.getMessage()); + view.showToastMessage(CitraApplication.getAppContext().getString(R.string.error_saving, fileName, e.getMessage()), false); + } + } + + + public static void saveCustomGameSettings(final String gameId, final HashMap sections) { + Set sortedSections = new TreeSet<>(sections.keySet()); + + for (String sectionKey : sortedSections) { + SettingSection section = sections.get(sectionKey); + + HashMap settings = section.getSettings(); + Set sortedKeySet = new TreeSet<>(settings.keySet()); + + for (String settingKey : sortedKeySet) { + Setting setting = settings.get(settingKey); + NativeLibrary.SetUserSetting(gameId, mapSectionNameFromIni(section.getName()), setting.getKey(), setting.getValueAsString()); + } + } + } + + private static String mapSectionNameFromIni(String generalSectionName) { + if (sectionsMap.getForward(generalSectionName) != null) { + return sectionsMap.getForward(generalSectionName); + } + + return generalSectionName; + } + + private static String mapSectionNameToIni(String generalSectionName) { + if (sectionsMap.getBackward(generalSectionName) != null) { + return sectionsMap.getBackward(generalSectionName); + } + + return generalSectionName; + } + + @NonNull + private static File getSettingsFile(String fileName) { + return new File( + DirectoryInitialization.getUserDirectory() + "/config/" + fileName + ".ini"); + } + + private static File getCustomGameSettingsFile(String gameId) { + return new File(DirectoryInitialization.getUserDirectory() + "/GameSettings/" + gameId + ".ini"); + } + + private static SettingSection sectionFromLine(String line, boolean isCustomGame) { + String sectionName = line.substring(1, line.length() - 1); + if (isCustomGame) { + sectionName = mapSectionNameToIni(sectionName); + } + return new SettingSection(sectionName); + } + + /** + * For a line of text, determines what type of data is being represented, and returns + * a Setting object containing this data. + * + * @param current The section currently being parsed by the consuming method. + * @param line The line of text being parsed. + * @return A typed Setting containing the key/value contained in the line. + */ + private static Setting settingFromLine(SettingSection current, String line) { + String[] splitLine = line.split("="); + + if (splitLine.length != 2) { + Log.warning("Skipping invalid config line \"" + line + "\""); + return null; + } + + String key = splitLine[0].trim(); + String value = splitLine[1].trim(); + + if (value.isEmpty()) { + Log.warning("Skipping null value in config line \"" + line + "\""); + return null; + } + + try { + int valueAsInt = Integer.parseInt(value); + + return new IntSetting(key, current.getName(), valueAsInt); + } catch (NumberFormatException ex) { + } + + try { + float valueAsFloat = Float.parseFloat(value); + + return new FloatSetting(key, current.getName(), valueAsFloat); + } catch (NumberFormatException ex) { + } + + return new StringSetting(key, current.getName(), value); + } + + /** + * 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 static void writeSection(Wini parser, SettingSection section) { + // Write the section header. + String header = section.getName(); + + // Write this section's values. + HashMap settings = section.getSettings(); + Set keySet = settings.keySet(); + + for (String key : keySet) { + Setting setting = settings.get(key); + parser.put(header, setting.getKey(), setting.getValueAsString()); + } + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/fragments/CustomFilePickerFragment.java b/src/android/app/src/main/java/org/citra/citra_emu/fragments/CustomFilePickerFragment.java new file mode 100644 index 000000000..c18ecd4c3 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/fragments/CustomFilePickerFragment.java @@ -0,0 +1,120 @@ +package org.citra.citra_emu.fragments; + +import android.net.Uri; +import android.os.Bundle; +import android.os.Environment; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.appcompat.widget.Toolbar; +import androidx.core.content.FileProvider; + +import com.nononsenseapps.filepicker.FilePickerFragment; + +import org.citra.citra_emu.R; + +import java.io.File; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +public class CustomFilePickerFragment extends FilePickerFragment { + private static String ALL_FILES = "*"; + private int mTitle; + private static List extensions = Collections.singletonList(ALL_FILES); + + @NonNull + @Override + public Uri toUri(@NonNull final File file) { + return FileProvider + .getUriForFile(getContext(), + getContext().getApplicationContext().getPackageName() + ".filesprovider", + file); + } + + @Override + public void onActivityCreated(Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + if (mode == MODE_DIR) { + TextView ok = getActivity().findViewById(R.id.nnf_button_ok); + ok.setText(R.string.select_dir); + + TextView cancel = getActivity().findViewById(R.id.nnf_button_cancel); + cancel.setVisibility(View.GONE); + } + } + + @Override + protected View inflateRootView(LayoutInflater inflater, ViewGroup container) { + View view = super.inflateRootView(inflater, container); + if (mTitle != 0) { + Toolbar toolbar = view.findViewById(com.nononsenseapps.filepicker.R.id.nnf_picker_toolbar); + ViewGroup parent = (ViewGroup) toolbar.getParent(); + int index = parent.indexOfChild(toolbar); + View newToolbar = inflater.inflate(R.layout.filepicker_toolbar, toolbar, false); + TextView title = newToolbar.findViewById(R.id.filepicker_title); + title.setText(mTitle); + parent.removeView(toolbar); + parent.addView(newToolbar, index); + } + return view; + } + + public void setTitle(int title) { + mTitle = title; + } + + public void setAllowedExtensions(String allowedExtensions) { + if (allowedExtensions == null) + return; + + extensions = Arrays.asList(allowedExtensions.split(",")); + } + + @Override + protected boolean isItemVisible(@NonNull final File file) { + // Some users jump to the conclusion that Dolphin isn't able to detect their + // files if the files don't show up in the file picker when mode == MODE_DIR. + // To avoid this, show files even when the user needs to select a directory. + return (showHiddenItems || !file.isHidden()) && + (file.isDirectory() || extensions.contains(ALL_FILES) || + extensions.contains(fileExtension(file.getName()).toLowerCase())); + } + + @Override + public boolean isCheckable(@NonNull final File file) { + // We need to make a small correction to the isCheckable logic due to + // overriding isItemVisible to show files when mode == MODE_DIR. + // AbstractFilePickerFragment always treats files as checkable when + // allowExistingFile == true, but we don't want files to be checkable when mode == MODE_DIR. + return super.isCheckable(file) && !(mode == MODE_DIR && file.isFile()); + } + + @Override + public void goUp() { + if (Environment.getExternalStorageDirectory().getPath().equals(mCurrentPath.getPath())) { + goToDir(new File("/storage/")); + return; + } + if (mCurrentPath.equals(new File("/storage/"))){ + return; + } + super.goUp(); + } + + @Override + public void onClickDir(@NonNull View view, @NonNull DirViewHolder viewHolder) { + if(viewHolder.file.equals(new File("/storage/emulated/"))) + viewHolder.file = new File("/storage/emulated/0/"); + super.onClickDir(view, viewHolder); + } + + private static String fileExtension(@NonNull String filename) { + int i = filename.lastIndexOf('.'); + return i < 0 ? "" : filename.substring(i + 1); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/fragments/EmulationFragment.java b/src/android/app/src/main/java/org/citra/citra_emu/fragments/EmulationFragment.java new file mode 100644 index 000000000..445faa047 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/fragments/EmulationFragment.java @@ -0,0 +1,378 @@ +package org.citra.citra_emu.fragments; + +import android.content.Context; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.os.Bundle; +import android.os.Handler; +import android.preference.PreferenceManager; +import android.view.Choreographer; +import android.view.LayoutInflater; +import android.view.Surface; +import android.view.SurfaceHolder; +import android.view.SurfaceView; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.fragment.app.Fragment; +import androidx.localbroadcastmanager.content.LocalBroadcastManager; + +import org.citra.citra_emu.NativeLibrary; +import org.citra.citra_emu.R; +import org.citra.citra_emu.activities.EmulationActivity; +import org.citra.citra_emu.overlay.InputOverlay; +import org.citra.citra_emu.utils.DirectoryInitialization; +import org.citra.citra_emu.utils.DirectoryInitialization.DirectoryInitializationState; +import org.citra.citra_emu.utils.DirectoryStateReceiver; +import org.citra.citra_emu.utils.EmulationMenuSettings; +import org.citra.citra_emu.utils.Log; + +public final class EmulationFragment extends Fragment implements SurfaceHolder.Callback, Choreographer.FrameCallback { + private static final String KEY_GAMEPATH = "gamepath"; + + private static final Handler perfStatsUpdateHandler = new Handler(); + + private SharedPreferences mPreferences; + + private InputOverlay mInputOverlay; + + private EmulationState mEmulationState; + + private DirectoryStateReceiver directoryStateReceiver; + + private EmulationActivity activity; + + private TextView mPerfStats; + + private Runnable perfStatsUpdater; + + public static EmulationFragment newInstance(String gamePath) { + Bundle args = new Bundle(); + args.putString(KEY_GAMEPATH, gamePath); + + EmulationFragment fragment = new EmulationFragment(); + fragment.setArguments(args); + return fragment; + } + + @Override + public void onAttach(@NonNull Context context) { + super.onAttach(context); + + if (context instanceof EmulationActivity) { + activity = (EmulationActivity) context; + NativeLibrary.setEmulationActivity((EmulationActivity) context); + } else { + throw new IllegalStateException("EmulationFragment must have EmulationActivity parent"); + } + } + + /** + * Initialize anything that doesn't depend on the layout / views in here. + */ + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // So this fragment doesn't restart on configuration changes; i.e. rotation. + setRetainInstance(true); + + mPreferences = PreferenceManager.getDefaultSharedPreferences(getActivity()); + + String gamePath = getArguments().getString(KEY_GAMEPATH); + mEmulationState = new EmulationState(gamePath); + } + + /** + * Initialize the UI and start emulation in here. + */ + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + View contents = inflater.inflate(R.layout.fragment_emulation, container, false); + + SurfaceView surfaceView = contents.findViewById(R.id.surface_emulation); + surfaceView.getHolder().addCallback(this); + + mInputOverlay = contents.findViewById(R.id.surface_input_overlay); + mPerfStats = contents.findViewById(R.id.show_fps_text); + + Button doneButton = contents.findViewById(R.id.done_control_config); + if (doneButton != null) { + doneButton.setOnClickListener(v -> stopConfiguringControls()); + } + + // Show/hide the "Show FPS" overlay + updateShowFpsOverlay(); + + // The new Surface created here will get passed to the native code via onSurfaceChanged. + return contents; + } + + @Override + public void onResume() { + super.onResume(); + Choreographer.getInstance().postFrameCallback(this); + if (DirectoryInitialization.areCitraDirectoriesReady()) { + mEmulationState.run(activity.isActivityRecreated()); + } else { + setupCitraDirectoriesThenStartEmulation(); + } + } + + @Override + public void onPause() { + if (directoryStateReceiver != null) { + LocalBroadcastManager.getInstance(getActivity()).unregisterReceiver(directoryStateReceiver); + directoryStateReceiver = null; + } + + if (mEmulationState.isRunning()) { + mEmulationState.pause(); + } + + Choreographer.getInstance().removeFrameCallback(this); + super.onPause(); + } + + @Override + public void onDetach() { + NativeLibrary.clearEmulationActivity(); + super.onDetach(); + } + + private void setupCitraDirectoriesThenStartEmulation() { + IntentFilter statusIntentFilter = new IntentFilter( + DirectoryInitialization.BROADCAST_ACTION); + + directoryStateReceiver = + new DirectoryStateReceiver(directoryInitializationState -> + { + if (directoryInitializationState == + DirectoryInitializationState.CITRA_DIRECTORIES_INITIALIZED) { + mEmulationState.run(activity.isActivityRecreated()); + } else if (directoryInitializationState == + DirectoryInitializationState.EXTERNAL_STORAGE_PERMISSION_NEEDED) { + Toast.makeText(getContext(), R.string.write_permission_needed, Toast.LENGTH_SHORT) + .show(); + } else if (directoryInitializationState == + DirectoryInitializationState.CANT_FIND_EXTERNAL_STORAGE) { + Toast.makeText(getContext(), R.string.external_storage_not_mounted, + Toast.LENGTH_SHORT) + .show(); + } + }); + + // Registers the DirectoryStateReceiver and its intent filters + LocalBroadcastManager.getInstance(getActivity()).registerReceiver( + directoryStateReceiver, + statusIntentFilter); + DirectoryInitialization.start(getActivity()); + } + + public void refreshInputOverlay() { + mInputOverlay.refreshControls(); + } + + public void resetInputOverlay() { + // Reset button scale + SharedPreferences.Editor editor = mPreferences.edit(); + editor.putInt("controlScale", 50); + editor.apply(); + + mInputOverlay.resetButtonPlacement(); + } + + public void updateShowFpsOverlay() { + if (EmulationMenuSettings.getShowFps()) { + final int SYSTEM_FPS = 0; + final int FPS = 1; + final int FRAMETIME = 2; + final int SPEED = 3; + + perfStatsUpdater = () -> + { + final double[] perfStats = NativeLibrary.GetPerfStats(); + if (perfStats[FPS] > 0) { + mPerfStats.setText(String.format("FPS: %d Speed: %d%%", (int) (perfStats[FPS] + 0.5), + (int) (perfStats[SPEED] * 100.0 + 0.5))); + } + + perfStatsUpdateHandler.postDelayed(perfStatsUpdater, 3000); + }; + perfStatsUpdateHandler.post(perfStatsUpdater); + + mPerfStats.setVisibility(View.VISIBLE); + } else { + if (perfStatsUpdater != null) { + perfStatsUpdateHandler.removeCallbacks(perfStatsUpdater); + } + + mPerfStats.setVisibility(View.GONE); + } + } + + @Override + public void surfaceCreated(SurfaceHolder holder) { + // We purposely don't do anything here. + // All work is done in surfaceChanged, which we are guaranteed to get even for surface creation. + } + + @Override + public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) { + Log.debug("[EmulationFragment] Surface changed. Resolution: " + width + "x" + height); + mEmulationState.newSurface(holder.getSurface()); + } + + @Override + public void surfaceDestroyed(SurfaceHolder holder) { + mEmulationState.clearSurface(); + } + + @Override + public void doFrame(long frameTimeNanos) { + Choreographer.getInstance().postFrameCallback(this); + NativeLibrary.DoFrame(); + } + + public void stopEmulation() { + mEmulationState.stop(); + } + + public void startConfiguringControls() { + getView().findViewById(R.id.done_control_config).setVisibility(View.VISIBLE); + mInputOverlay.setIsInEditMode(true); + } + + public void stopConfiguringControls() { + getView().findViewById(R.id.done_control_config).setVisibility(View.GONE); + mInputOverlay.setIsInEditMode(false); + } + + public boolean isConfiguringControls() { + return mInputOverlay.isInEditMode(); + } + + private static class EmulationState { + private final String mGamePath; + private State state; + private Surface mSurface; + private boolean mRunWhenSurfaceIsValid; + + EmulationState(String gamePath) { + mGamePath = gamePath; + // Starting state is stopped. + state = State.STOPPED; + } + + public synchronized boolean isStopped() { + return state == State.STOPPED; + } + + // Getters for the current state + + public synchronized boolean isPaused() { + return state == State.PAUSED; + } + + public synchronized boolean isRunning() { + return state == State.RUNNING; + } + + public synchronized void stop() { + if (state != State.STOPPED) { + Log.debug("[EmulationFragment] Stopping emulation."); + state = State.STOPPED; + NativeLibrary.StopEmulation(); + } else { + Log.warning("[EmulationFragment] Stop called while already stopped."); + } + } + + // State changing methods + + public synchronized void pause() { + if (state != State.PAUSED) { + state = State.PAUSED; + Log.debug("[EmulationFragment] Pausing emulation."); + + // Release the surface before pausing, since emulation has to be running for that. + NativeLibrary.SurfaceDestroyed(); + NativeLibrary.PauseEmulation(); + } else { + Log.warning("[EmulationFragment] Pause called while already paused."); + } + } + + public synchronized void run(boolean isActivityRecreated) { + 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 (mSurface != null) { + runWithValidSurface(); + } else { + mRunWhenSurfaceIsValid = true; + } + } + + // Surface callbacks + public synchronized void newSurface(Surface surface) { + mSurface = surface; + if (mRunWhenSurfaceIsValid) { + runWithValidSurface(); + } + } + + public synchronized void clearSurface() { + if (mSurface == null) { + Log.warning("[EmulationFragment] clearSurface called, but surface already null."); + } else { + mSurface = null; + Log.debug("[EmulationFragment] Surface destroyed."); + + if (state == State.RUNNING) { + NativeLibrary.SurfaceDestroyed(); + state = State.PAUSED; + } else if (state == State.PAUSED) { + Log.warning("[EmulationFragment] Surface cleared while emulation paused."); + } else { + Log.warning("[EmulationFragment] Surface cleared while emulation stopped."); + } + } + } + + private void runWithValidSurface() { + mRunWhenSurfaceIsValid = false; + if (state == State.STOPPED) { + NativeLibrary.SurfaceChanged(mSurface); + Thread mEmulationThread = new Thread(() -> + { + Log.debug("[EmulationFragment] Starting emulation thread."); + NativeLibrary.Run(mGamePath); + }, "NativeEmulation"); + mEmulationThread.start(); + + } else if (state == State.PAUSED) { + Log.debug("[EmulationFragment] Resuming emulation."); + NativeLibrary.SurfaceChanged(mSurface); + NativeLibrary.UnPauseEmulation(); + } else { + Log.debug("[EmulationFragment] Bug, run called while already running."); + } + state = State.RUNNING; + } + + private enum State { + STOPPED, RUNNING, PAUSED + } + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/model/Game.java b/src/android/app/src/main/java/org/citra/citra_emu/model/Game.java new file mode 100644 index 000000000..a4ffc59c7 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/model/Game.java @@ -0,0 +1,76 @@ +package org.citra.citra_emu.model; + +import android.content.ContentValues; +import android.database.Cursor; + +import java.nio.file.Paths; + +public final class Game { + private String mTitle; + private String mDescription; + private String mPath; + private String mGameId; + private String mCompany; + private String mRegions; + + public Game(String title, String description, String regions, String path, + String gameId, String company) { + mTitle = title; + mDescription = description; + mRegions = regions; + mPath = path; + mGameId = gameId; + mCompany = company; + } + + public static ContentValues asContentValues(String title, String description, String regions, String path, String gameId, String company) { + ContentValues values = new ContentValues(); + + if (gameId.isEmpty()) { + // Homebrew, etc. may not have a game ID, use filename as a unique identifier + gameId = Paths.get(path).getFileName().toString(); + } + + values.put(GameDatabase.KEY_GAME_TITLE, title); + values.put(GameDatabase.KEY_GAME_DESCRIPTION, description); + values.put(GameDatabase.KEY_GAME_REGIONS, regions); + values.put(GameDatabase.KEY_GAME_PATH, path); + values.put(GameDatabase.KEY_GAME_ID, gameId); + values.put(GameDatabase.KEY_GAME_COMPANY, company); + + return values; + } + + public static Game fromCursor(Cursor cursor) { + return new Game(cursor.getString(GameDatabase.GAME_COLUMN_TITLE), + cursor.getString(GameDatabase.GAME_COLUMN_DESCRIPTION), + cursor.getString(GameDatabase.GAME_COLUMN_REGIONS), + cursor.getString(GameDatabase.GAME_COLUMN_PATH), + cursor.getString(GameDatabase.GAME_COLUMN_GAME_ID), + cursor.getString(GameDatabase.GAME_COLUMN_COMPANY)); + } + + public String getTitle() { + return mTitle; + } + + public String getDescription() { + return mDescription; + } + + public String getCompany() { + return mCompany; + } + + public String getRegions() { + return mRegions; + } + + public String getPath() { + return mPath; + } + + public String getGameId() { + return mGameId; + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/model/GameDatabase.java b/src/android/app/src/main/java/org/citra/citra_emu/model/GameDatabase.java new file mode 100644 index 000000000..215528541 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/model/GameDatabase.java @@ -0,0 +1,280 @@ +package org.citra.citra_emu.model; + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteOpenHelper; + +import org.citra.citra_emu.NativeLibrary; +import org.citra.citra_emu.utils.Log; + +import java.io.File; +import java.lang.reflect.Array; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +import rx.Observable; + +/** + * A helper class that provides several utilities simplifying interaction with + * the SQLite database. + */ +public final class GameDatabase extends SQLiteOpenHelper { + public static final int COLUMN_DB_ID = 0; + public static final int GAME_COLUMN_PATH = 1; + public static final int GAME_COLUMN_TITLE = 2; + public static final int GAME_COLUMN_DESCRIPTION = 3; + public static final int GAME_COLUMN_REGIONS = 4; + public static final int GAME_COLUMN_GAME_ID = 5; + public static final int GAME_COLUMN_COMPANY = 6; + public static final int FOLDER_COLUMN_PATH = 1; + public static final String KEY_DB_ID = "_id"; + public static final String KEY_GAME_PATH = "path"; + public static final String KEY_GAME_TITLE = "title"; + public static final String KEY_GAME_DESCRIPTION = "description"; + public static final String KEY_GAME_REGIONS = "regions"; + public static final String KEY_GAME_ID = "game_id"; + public static final String KEY_GAME_COMPANY = "company"; + public static final String KEY_FOLDER_PATH = "path"; + public static final String TABLE_NAME_FOLDERS = "folders"; + public static final String TABLE_NAME_GAMES = "games"; + private static final int DB_VERSION = 2; + private static final String TYPE_PRIMARY = " INTEGER PRIMARY KEY"; + private static final String TYPE_INTEGER = " INTEGER"; + private static final String TYPE_STRING = " TEXT"; + + private static final String CONSTRAINT_UNIQUE = " UNIQUE"; + + private static final String SEPARATOR = ", "; + + private static final String SQL_CREATE_GAMES = "CREATE TABLE " + TABLE_NAME_GAMES + "(" + + KEY_DB_ID + TYPE_PRIMARY + SEPARATOR + + KEY_GAME_PATH + TYPE_STRING + SEPARATOR + + KEY_GAME_TITLE + TYPE_STRING + SEPARATOR + + KEY_GAME_DESCRIPTION + TYPE_STRING + SEPARATOR + + KEY_GAME_REGIONS + TYPE_STRING + SEPARATOR + + KEY_GAME_ID + TYPE_STRING + SEPARATOR + + KEY_GAME_COMPANY + TYPE_STRING + ")"; + + private static final String SQL_CREATE_FOLDERS = "CREATE TABLE " + TABLE_NAME_FOLDERS + "(" + + KEY_DB_ID + TYPE_PRIMARY + SEPARATOR + + KEY_FOLDER_PATH + TYPE_STRING + CONSTRAINT_UNIQUE + ")"; + + private static final String SQL_DELETE_FOLDERS = "DROP TABLE IF EXISTS " + TABLE_NAME_FOLDERS; + private static final String SQL_DELETE_GAMES = "DROP TABLE IF EXISTS " + TABLE_NAME_GAMES; + + public GameDatabase(Context context) { + // Superclass constructor builds a database or uses an existing one. + super(context, "games.db", null, DB_VERSION); + } + + @Override + public void onCreate(SQLiteDatabase database) { + Log.debug("[GameDatabase] GameDatabase - Creating database..."); + + execSqlAndLog(database, SQL_CREATE_GAMES); + execSqlAndLog(database, SQL_CREATE_FOLDERS); + } + + @Override + public void onDowngrade(SQLiteDatabase database, int oldVersion, int newVersion) { + Log.verbose("[GameDatabase] Downgrades not supporting, clearing databases.."); + execSqlAndLog(database, SQL_DELETE_FOLDERS); + execSqlAndLog(database, SQL_CREATE_FOLDERS); + + execSqlAndLog(database, SQL_DELETE_GAMES); + execSqlAndLog(database, SQL_CREATE_GAMES); + } + + @Override + public void onUpgrade(SQLiteDatabase database, int oldVersion, int newVersion) { + Log.info("[GameDatabase] Upgrading database from schema version " + oldVersion + " to " + + newVersion); + + // Delete all the games + execSqlAndLog(database, SQL_DELETE_GAMES); + execSqlAndLog(database, SQL_CREATE_GAMES); + } + + public void resetDatabase(SQLiteDatabase database) { + execSqlAndLog(database, SQL_DELETE_FOLDERS); + execSqlAndLog(database, SQL_CREATE_FOLDERS); + + execSqlAndLog(database, SQL_DELETE_GAMES); + execSqlAndLog(database, SQL_CREATE_GAMES); + } + + public void scanLibrary(SQLiteDatabase database) { + // Before scanning known folders, go through the game table and remove any entries for which the file itself is missing. + Cursor fileCursor = database.query(TABLE_NAME_GAMES, + null, // Get all columns. + null, // Get all rows. + null, + null, // No grouping. + null, + null); // Order of games is irrelevant. + + // Possibly overly defensive, but ensures that moveToNext() does not skip a row. + fileCursor.moveToPosition(-1); + + while (fileCursor.moveToNext()) { + String gamePath = fileCursor.getString(GAME_COLUMN_PATH); + File game = new File(gamePath); + + if (!game.exists()) { + Log.error("[GameDatabase] Game file no longer exists. Removing from the library: " + + gamePath); + database.delete(TABLE_NAME_GAMES, + KEY_DB_ID + " = ?", + new String[]{Long.toString(fileCursor.getLong(COLUMN_DB_ID))}); + } + } + + // Get a cursor listing all the folders the user has added to the library. + Cursor folderCursor = database.query(TABLE_NAME_FOLDERS, + null, // Get all columns. + null, // Get all rows. + null, + null, // No grouping. + null, + null); // Order of folders is irrelevant. + + Set allowedExtensions = new HashSet(Arrays.asList( + ".3ds", ".3dsx", ".elf", ".axf", ".cci", ".cxi", ".app", ".rar", ".zip", ".7z", ".torrent", ".tar", ".gz")); + + // Possibly overly defensive, but ensures that moveToNext() does not skip a row. + folderCursor.moveToPosition(-1); + + // Iterate through all results of the DB query (i.e. all folders in the library.) + while (folderCursor.moveToNext()) { + String folderPath = folderCursor.getString(FOLDER_COLUMN_PATH); + + File folder = new File(folderPath); + // If the folder is empty because it no longer exists, remove it from the library. + if (!folder.exists()) { + Log.error( + "[GameDatabase] Folder no longer exists. Removing from the library: " + folderPath); + database.delete(TABLE_NAME_FOLDERS, + KEY_DB_ID + " = ?", + new String[]{Long.toString(folderCursor.getLong(COLUMN_DB_ID))}); + } + + addGamesRecursive(database, folder, allowedExtensions, 3); + } + + fileCursor.close(); + folderCursor.close(); + + Arrays.stream(NativeLibrary.GetInstalledGamePaths()) + .forEach(filePath -> attemptToAddGame(database, filePath)); + + database.close(); + } + + private static void addGamesRecursive(SQLiteDatabase database, File parent, Set allowedExtensions, int depth) { + if (depth <= 0) { + return; + } + + File[] children = parent.listFiles(); + if (children != null) { + for (File file : children) { + if (file.isHidden()) { + continue; + } + + if (file.isDirectory()) { + Set newExtensions = new HashSet<>(Arrays.asList( + ".3ds", ".3dsx", ".elf", ".axf", ".cci", ".cxi", ".app")); + addGamesRecursive(database, file, newExtensions, depth - 1); + } else { + String filePath = file.getPath(); + + int extensionStart = filePath.lastIndexOf('.'); + if (extensionStart > 0) { + String fileExtension = filePath.substring(extensionStart); + + // Check that the file has an extension we care about before trying to read out of it. + if (allowedExtensions.contains(fileExtension.toLowerCase())) { + attemptToAddGame(database, filePath); + } + } + } + } + } + } + + private static void attemptToAddGame(SQLiteDatabase database, String filePath) { + String name = NativeLibrary.GetTitle(filePath); + + // If the game's title field is empty, use the filename. + if (name.isEmpty()) { + name = filePath.substring(filePath.lastIndexOf("/") + 1); + } + + String gameId = NativeLibrary.GetGameId(filePath); + + // If the game's ID field is empty, use the filename without extension. + if (gameId.isEmpty()) { + gameId = filePath.substring(filePath.lastIndexOf("/") + 1, + filePath.lastIndexOf(".")); + } + + ContentValues game = Game.asContentValues(name, + NativeLibrary.GetDescription(filePath).replace("\n", " "), + NativeLibrary.GetRegions(filePath), + filePath, + gameId, + NativeLibrary.GetCompany(filePath)); + + // Try to update an existing game first. + int rowsMatched = database.update(TABLE_NAME_GAMES, // Which table to update. + game, + // The values to fill the row with. + KEY_GAME_ID + " = ?", + // The WHERE clause used to find the right row. + new String[]{game.getAsString( + KEY_GAME_ID)}); // The ? in WHERE clause is replaced with this, + // which is provided as an array because there + // could potentially be more than one argument. + + // If update fails, insert a new game instead. + if (rowsMatched == 0) { + Log.verbose("[GameDatabase] Adding game: " + game.getAsString(KEY_GAME_TITLE)); + database.insert(TABLE_NAME_GAMES, null, game); + } else { + Log.verbose("[GameDatabase] Updated game: " + game.getAsString(KEY_GAME_TITLE)); + } + } + + public Observable getGames() { + return Observable.create(subscriber -> + { + Log.info("[GameDatabase] Reading games list..."); + + SQLiteDatabase database = getReadableDatabase(); + Cursor resultCursor = database.query( + TABLE_NAME_GAMES, + null, + null, + null, + null, + null, + KEY_GAME_TITLE + " ASC" + ); + + // Pass the result cursor to the consumer. + subscriber.onNext(resultCursor); + + // Tell the consumer we're done; it will unsubscribe implicitly. + subscriber.onCompleted(); + }); + } + + private void execSqlAndLog(SQLiteDatabase database, String sql) { + Log.verbose("[GameDatabase] Executing SQL: " + sql); + database.execSQL(sql); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/model/GameProvider.java b/src/android/app/src/main/java/org/citra/citra_emu/model/GameProvider.java new file mode 100644 index 000000000..33b289fc4 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/model/GameProvider.java @@ -0,0 +1,138 @@ +package org.citra.citra_emu.model; + +import android.content.ContentProvider; +import android.content.ContentValues; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.net.Uri; + +import androidx.annotation.NonNull; + +import org.citra.citra_emu.BuildConfig; +import org.citra.citra_emu.utils.Log; + +/** + * Provides an interface allowing Activities to interact with the SQLite database. + * CRUD methods in this class can be called by Activities using getContentResolver(). + */ +public final class GameProvider extends ContentProvider { + public static final String REFRESH_LIBRARY = "refresh"; + public static final String RESET_LIBRARY = "reset"; + + public static final String AUTHORITY = "content://" + BuildConfig.APPLICATION_ID + ".provider"; + public static final Uri URI_FOLDER = + Uri.parse(AUTHORITY + "/" + GameDatabase.TABLE_NAME_FOLDERS + "/"); + public static final Uri URI_REFRESH = Uri.parse(AUTHORITY + "/" + REFRESH_LIBRARY + "/"); + public static final Uri URI_RESET = Uri.parse(AUTHORITY + "/" + RESET_LIBRARY + "/"); + + public static final String MIME_TYPE_FOLDER = "vnd.android.cursor.item/vnd.dolphin.folder"; + public static final String MIME_TYPE_GAME = "vnd.android.cursor.item/vnd.dolphin.game"; + + + private GameDatabase mDbHelper; + + @Override + public boolean onCreate() { + Log.info("[GameProvider] Creating Content Provider..."); + + mDbHelper = new GameDatabase(getContext()); + + return true; + } + + @Override + public Cursor query(@NonNull Uri uri, String[] projection, String selection, + String[] selectionArgs, String sortOrder) { + Log.info("[GameProvider] Querying URI: " + uri); + + SQLiteDatabase db = mDbHelper.getReadableDatabase(); + + String table = uri.getLastPathSegment(); + + if (table == null) { + Log.error("[GameProvider] Badly formatted URI: " + uri); + return null; + } + + Cursor cursor = db.query(table, projection, selection, selectionArgs, null, null, sortOrder); + cursor.setNotificationUri(getContext().getContentResolver(), uri); + + return cursor; + } + + @Override + public String getType(@NonNull Uri uri) { + Log.verbose("[GameProvider] Getting MIME type for URI: " + uri); + String lastSegment = uri.getLastPathSegment(); + + if (lastSegment == null) { + Log.error("[GameProvider] Badly formatted URI: " + uri); + return null; + } + + if (lastSegment.equals(GameDatabase.TABLE_NAME_FOLDERS)) { + return MIME_TYPE_FOLDER; + } else if (lastSegment.equals(GameDatabase.TABLE_NAME_GAMES)) { + return MIME_TYPE_GAME; + } + + Log.error("[GameProvider] Unknown MIME type for URI: " + uri); + return null; + } + + @Override + public Uri insert(@NonNull Uri uri, ContentValues values) { + Log.info("[GameProvider] Inserting row at URI: " + uri); + + SQLiteDatabase database = mDbHelper.getWritableDatabase(); + String table = uri.getLastPathSegment(); + + if (table != null) { + if (table.equals(RESET_LIBRARY)) { + mDbHelper.resetDatabase(database); + return uri; + } + if (table.equals(REFRESH_LIBRARY)) { + Log.info( + "[GameProvider] URI specified table REFRESH_LIBRARY. No insertion necessary; refreshing library contents..."); + mDbHelper.scanLibrary(database); + return uri; + } + + long id = database.insertWithOnConflict(table, null, values, SQLiteDatabase.CONFLICT_IGNORE); + + // If insertion was successful... + if (id > 0) { + // If we just added a folder, add its contents to the game list. + if (table.equals(GameDatabase.TABLE_NAME_FOLDERS)) { + mDbHelper.scanLibrary(database); + } + + // Notify the UI that its contents should be refreshed. + getContext().getContentResolver().notifyChange(uri, null); + uri = Uri.withAppendedPath(uri, Long.toString(id)); + } else { + Log.error("[GameProvider] Row already exists: " + uri + " id: " + id); + } + } else { + Log.error("[GameProvider] Badly formatted URI: " + uri); + } + + database.close(); + + return uri; + } + + @Override + public int delete(@NonNull Uri uri, String selection, String[] selectionArgs) { + Log.error("[GameProvider] Delete operations unsupported. URI: " + uri); + return 0; + } + + @Override + public int update(@NonNull Uri uri, ContentValues values, String selection, + String[] selectionArgs) { + Log.error("[GameProvider] Update operations unsupported. URI: " + uri); + return 0; + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlay.java b/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlay.java new file mode 100644 index 000000000..cdb2f7666 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlay.java @@ -0,0 +1,878 @@ +/** + * Copyright 2013 Dolphin Emulator Project + * Licensed under GPLv2+ + * Refer to the license.txt file included. + */ + +package org.citra.citra_emu.overlay; + +import android.app.Activity; +import android.content.Context; +import android.content.SharedPreferences; +import android.content.res.Configuration; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Canvas; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.preference.PreferenceManager; +import android.util.AttributeSet; +import android.util.DisplayMetrics; +import android.view.Display; +import android.view.MotionEvent; +import android.view.SurfaceView; +import android.view.View; +import android.view.View.OnTouchListener; + +import org.citra.citra_emu.NativeLibrary; +import org.citra.citra_emu.NativeLibrary.ButtonState; +import org.citra.citra_emu.NativeLibrary.ButtonType; +import org.citra.citra_emu.R; +import org.citra.citra_emu.utils.EmulationMenuSettings; + +import java.util.HashSet; +import java.util.Set; + +/** + * Draws the interactive input overlay on top of the + * {@link SurfaceView} that is rendering emulation. + */ +public final class InputOverlay extends SurfaceView implements OnTouchListener { + private final Set overlayButtons = new HashSet<>(); + private final Set overlayDpads = new HashSet<>(); + private final Set overlayJoysticks = new HashSet<>(); + + private boolean mIsInEditMode = false; + private InputOverlayDrawableButton mButtonBeingConfigured; + private InputOverlayDrawableDpad mDpadBeingConfigured; + private InputOverlayDrawableJoystick mJoystickBeingConfigured; + + private SharedPreferences mPreferences; + + // Stores the ID of the pointer that interacted with the 3DS touchscreen. + private int mTouchscreenPointerId = -1; + + /** + * Constructor + * + * @param context The current {@link Context}. + * @param attrs {@link AttributeSet} for parsing XML attributes. + */ + public InputOverlay(Context context, AttributeSet attrs) { + super(context, attrs); + + mPreferences = PreferenceManager.getDefaultSharedPreferences(getContext()); + if (!mPreferences.getBoolean("OverlayInit", false)) { + defaultOverlay(); + } + + // Reset 3ds touchscreen pointer ID + mTouchscreenPointerId = -1; + + // Load the controls. + refreshControls(); + + // Set the on touch listener. + setOnTouchListener(this); + + // Force draw + setWillNotDraw(false); + + // Request focus for the overlay so it has priority on presses. + requestFocus(); + } + + /** + * Resizes a {@link Bitmap} by a given scale factor + * + * @param context The current {@link Context} + * @param bitmap The {@link Bitmap} to scale. + * @param scale The scale factor for the bitmap. + * @return The scaled {@link Bitmap} + */ + public static Bitmap resizeBitmap(Context context, Bitmap bitmap, float scale) { + // Determine the button size based on the smaller screen dimension. + // This makes sure the buttons are the same size in both portrait and landscape. + DisplayMetrics dm = context.getResources().getDisplayMetrics(); + int minDimension = Math.min(dm.widthPixels, dm.heightPixels); + + return Bitmap.createScaledBitmap(bitmap, + (int) (minDimension * scale), + (int) (minDimension * scale), + true); + } + + /** + * Initializes an InputOverlayDrawableButton, given by resId, with all of the + * parameters set for it to be properly shown on the InputOverlay. + *

+ * This works due to the way the X and Y coordinates are stored within + * the {@link SharedPreferences}. + *

+ * In the input overlay configuration menu, + * once a touch event begins and then ends (ie. Organizing the buttons to one's own liking for the overlay). + * the X and Y coordinates of the button at the END of its touch event + * (when you remove your finger/stylus from the touchscreen) are then stored + * within a SharedPreferences instance so that those values can be retrieved here. + *

+ * This has a few benefits over the conventional way of storing the values + * (ie. within the Citra ini file). + *

    + *
  • No native calls
  • + *
  • Keeps Android-only values inside the Android environment
  • + *
+ *

+ * Technically no modifications should need to be performed on the returned + * InputOverlayDrawableButton. Simply add it to the HashSet of overlay items and wait + * for Android to call the onDraw method. + * + * @param context The current {@link Context}. + * @param defaultResId The resource ID of the {@link Drawable} to get the {@link Bitmap} of (Default State). + * @param pressedResId The resource ID of the {@link Drawable} to get the {@link Bitmap} of (Pressed State). + * @param buttonId Identifier for determining what type of button the initialized InputOverlayDrawableButton represents. + * @return An {@link InputOverlayDrawableButton} with the correct drawing bounds set. + */ + private static InputOverlayDrawableButton initializeOverlayButton(Context context, + int defaultResId, int pressedResId, int buttonId, String orientation) { + // Resources handle for fetching the initial Drawable resource. + final Resources res = context.getResources(); + + // SharedPreference to retrieve the X and Y coordinates for the InputOverlayDrawableButton. + final SharedPreferences sPrefs = PreferenceManager.getDefaultSharedPreferences(context); + + // Decide scale based on button ID and user preference + float scale; + + switch (buttonId) { + case ButtonType.BUTTON_HOME: + case ButtonType.BUTTON_START: + case ButtonType.BUTTON_SELECT: + scale = 0.08f; + break; + case ButtonType.TRIGGER_L: + case ButtonType.TRIGGER_R: + case ButtonType.BUTTON_ZL: + case ButtonType.BUTTON_ZR: + scale = 0.18f; + break; + default: + scale = 0.11f; + break; + } + + scale *= (sPrefs.getInt("controlScale", 50) + 50); + scale /= 100; + + // Initialize the InputOverlayDrawableButton. + final Bitmap defaultStateBitmap = + resizeBitmap(context, BitmapFactory.decodeResource(res, defaultResId), scale); + final Bitmap pressedStateBitmap = + resizeBitmap(context, BitmapFactory.decodeResource(res, pressedResId), scale); + final InputOverlayDrawableButton overlayDrawable = + new InputOverlayDrawableButton(res, defaultStateBitmap, pressedStateBitmap, buttonId); + + // The X and Y coordinates of the InputOverlayDrawableButton on the InputOverlay. + // These were set in the input overlay configuration menu. + String xKey; + String yKey; + + xKey = buttonId + orientation + "-X"; + yKey = buttonId + orientation + "-Y"; + + int drawableX = (int) sPrefs.getFloat(xKey, 0f); + int drawableY = (int) sPrefs.getFloat(yKey, 0f); + + int width = overlayDrawable.getWidth(); + int height = overlayDrawable.getHeight(); + + // Now set the bounds for the InputOverlayDrawableButton. + // This will dictate where on the screen (and the what the size) the InputOverlayDrawableButton will be. + overlayDrawable.setBounds(drawableX, drawableY, drawableX + width, drawableY + height); + + // Need to set the image's position + overlayDrawable.setPosition(drawableX, drawableY); + + return overlayDrawable; + } + + /** + * Initializes an {@link InputOverlayDrawableDpad} + * + * @param context The current {@link Context}. + * @param defaultResId The {@link Bitmap} resource ID of the default sate. + * @param pressedOneDirectionResId The {@link Bitmap} resource ID of the pressed sate in one direction. + * @param pressedTwoDirectionsResId The {@link Bitmap} resource ID of the pressed sate in two directions. + * @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. + * @return the initialized {@link InputOverlayDrawableDpad} + */ + private static InputOverlayDrawableDpad initializeOverlayDpad(Context context, + int defaultResId, + int pressedOneDirectionResId, + int pressedTwoDirectionsResId, + int buttonUp, + int buttonDown, + int buttonLeft, + int buttonRight, + String orientation) { + // Resources handle for fetching the initial Drawable resource. + final Resources res = context.getResources(); + + // SharedPreference to retrieve the X and Y coordinates for the InputOverlayDrawableDpad. + final SharedPreferences sPrefs = PreferenceManager.getDefaultSharedPreferences(context); + + // Decide scale based on button ID and user preference + float scale = 0.22f; + + scale *= (sPrefs.getInt("controlScale", 50) + 50); + scale /= 100; + + // Initialize the InputOverlayDrawableDpad. + final Bitmap defaultStateBitmap = + resizeBitmap(context, BitmapFactory.decodeResource(res, defaultResId), scale); + final Bitmap pressedOneDirectionStateBitmap = + resizeBitmap(context, BitmapFactory.decodeResource(res, pressedOneDirectionResId), + scale); + final Bitmap pressedTwoDirectionsStateBitmap = + resizeBitmap(context, BitmapFactory.decodeResource(res, pressedTwoDirectionsResId), + scale); + final InputOverlayDrawableDpad overlayDrawable = + new InputOverlayDrawableDpad(res, defaultStateBitmap, + pressedOneDirectionStateBitmap, pressedTwoDirectionsStateBitmap, + buttonUp, buttonDown, buttonLeft, buttonRight); + + // The X and Y coordinates of the InputOverlayDrawableDpad on the InputOverlay. + // These were set in the input overlay configuration menu. + int drawableX = (int) sPrefs.getFloat(buttonUp + orientation + "-X", 0f); + int drawableY = (int) sPrefs.getFloat(buttonUp + orientation + "-Y", 0f); + + int width = overlayDrawable.getWidth(); + int height = overlayDrawable.getHeight(); + + // Now set the bounds for the InputOverlayDrawableDpad. + // This will dictate where on the screen (and the what the size) the InputOverlayDrawableDpad will be. + overlayDrawable.setBounds(drawableX, drawableY, drawableX + width, drawableY + height); + + // Need to set the image's position + overlayDrawable.setPosition(drawableX, drawableY); + + return overlayDrawable; + } + + /** + * Initializes an {@link InputOverlayDrawableJoystick} + * + * @param context The current {@link Context} + * @param resOuter Resource ID for the outer image of the joystick (the static image that shows the circular bounds). + * @param defaultResInner Resource ID for the default inner image of the joystick (the one you actually move around). + * @param pressedResInner Resource ID for the pressed inner image of the joystick. + * @param joystick Identifier for which joystick this is. + * @return the initialized {@link InputOverlayDrawableJoystick}. + */ + private static InputOverlayDrawableJoystick initializeOverlayJoystick(Context context, + int resOuter, int defaultResInner, int pressedResInner, int joystick, String orientation) { + // Resources handle for fetching the initial Drawable resource. + final Resources res = context.getResources(); + + // SharedPreference to retrieve the X and Y coordinates for the InputOverlayDrawableJoystick. + final SharedPreferences sPrefs = PreferenceManager.getDefaultSharedPreferences(context); + + // Decide scale based on user preference + float scale = 0.275f; + scale *= (sPrefs.getInt("controlScale", 50) + 50); + scale /= 100; + + // Initialize the InputOverlayDrawableJoystick. + final Bitmap bitmapOuter = + resizeBitmap(context, BitmapFactory.decodeResource(res, resOuter), scale); + final Bitmap bitmapInnerDefault = BitmapFactory.decodeResource(res, defaultResInner); + final Bitmap bitmapInnerPressed = BitmapFactory.decodeResource(res, pressedResInner); + + // The X and Y coordinates of the InputOverlayDrawableButton on the InputOverlay. + // These were set in the input overlay configuration menu. + int drawableX = (int) sPrefs.getFloat(joystick + orientation + "-X", 0f); + int drawableY = (int) sPrefs.getFloat(joystick + orientation + "-Y", 0f); + + // Decide inner scale based on joystick ID + float outerScale = 1.f; + if (joystick == ButtonType.STICK_C) { + outerScale = 2.f; + } + + // Now set the bounds for the InputOverlayDrawableJoystick. + // This will dictate where on the screen (and the what the size) the InputOverlayDrawableJoystick will be. + int outerSize = bitmapOuter.getWidth(); + Rect outerRect = new Rect(drawableX, drawableY, drawableX + (int) (outerSize / outerScale), drawableY + (int) (outerSize / outerScale)); + Rect innerRect = new Rect(0, 0, (int) (outerSize / outerScale), (int) (outerSize / outerScale)); + + // Send the drawableId to the joystick so it can be referenced when saving control position. + final InputOverlayDrawableJoystick overlayDrawable + = new InputOverlayDrawableJoystick(res, bitmapOuter, + bitmapInnerDefault, bitmapInnerPressed, + outerRect, innerRect, joystick); + + // Need to set the image's position + overlayDrawable.setPosition(drawableX, drawableY); + + return overlayDrawable; + } + + @Override + public void draw(Canvas canvas) { + super.draw(canvas); + + for (InputOverlayDrawableButton button : overlayButtons) { + button.draw(canvas); + } + + for (InputOverlayDrawableDpad dpad : overlayDpads) { + dpad.draw(canvas); + } + + for (InputOverlayDrawableJoystick joystick : overlayJoysticks) { + joystick.draw(canvas); + } + } + + @Override + public boolean onTouch(View v, MotionEvent event) { + if (isInEditMode()) { + return onTouchWhileEditing(event); + } + + int pointerIndex = event.getActionIndex(); + + if (mPreferences.getBoolean("isTouchEnabled", true)) { + switch (event.getAction() & MotionEvent.ACTION_MASK) { + case MotionEvent.ACTION_DOWN: + case MotionEvent.ACTION_POINTER_DOWN: + if (NativeLibrary.onTouchEvent(event.getX(pointerIndex), event.getY(pointerIndex), true)) { + mTouchscreenPointerId = event.getPointerId(pointerIndex); + } + break; + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_POINTER_UP: + if (mTouchscreenPointerId == event.getPointerId(pointerIndex)) { + // We don't really care where the touch has been released. We only care whether it has been + // released or not. + NativeLibrary.onTouchEvent(0, 0, false); + mTouchscreenPointerId = -1; + } + break; + } + + for (int i = 0; i < event.getPointerCount(); i++) { + if (mTouchscreenPointerId == event.getPointerId(i)) { + NativeLibrary.onTouchMoved(event.getX(i), event.getY(i)); + } + } + } + + for (InputOverlayDrawableButton button : overlayButtons) { + // Determine the button state to apply based on the MotionEvent action flag. + switch (event.getAction() & MotionEvent.ACTION_MASK) { + case MotionEvent.ACTION_DOWN: + case MotionEvent.ACTION_POINTER_DOWN: + // If a pointer enters the bounds of a button, press that button. + if (button.getBounds() + .contains((int) event.getX(pointerIndex), (int) event.getY(pointerIndex))) { + button.setPressedState(true); + button.setTrackId(event.getPointerId(pointerIndex)); + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, button.getId(), + ButtonState.PRESSED); + } + break; + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_POINTER_UP: + // If a pointer ends, release the button it was pressing. + if (button.getTrackId() == event.getPointerId(pointerIndex)) { + button.setPressedState(false); + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, button.getId(), + ButtonState.RELEASED); + } + break; + } + } + + for (InputOverlayDrawableDpad dpad : overlayDpads) { + // Determine the button state to apply based on the MotionEvent action flag. + switch (event.getAction() & MotionEvent.ACTION_MASK) { + case MotionEvent.ACTION_DOWN: + case MotionEvent.ACTION_POINTER_DOWN: + // If a pointer enters the bounds of a button, press that button. + if (dpad.getBounds() + .contains((int) event.getX(pointerIndex), (int) event.getY(pointerIndex))) { + dpad.setTrackId(event.getPointerId(pointerIndex)); + } + break; + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_POINTER_UP: + // If a pointer ends, release the buttons. + if (dpad.getTrackId() == event.getPointerId(pointerIndex)) { + for (int i = 0; i < 4; i++) { + dpad.setState(InputOverlayDrawableDpad.STATE_DEFAULT); + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getId(i), + NativeLibrary.ButtonState.RELEASED); + } + dpad.setTrackId(-1); + } + break; + } + + if (dpad.getTrackId() != -1) { + for (int i = 0; i < event.getPointerCount(); i++) { + if (dpad.getTrackId() == event.getPointerId(i)) { + float touchX = event.getX(i); + float touchY = event.getY(i); + float maxY = dpad.getBounds().bottom; + float maxX = dpad.getBounds().right; + touchX -= dpad.getBounds().centerX(); + maxX -= dpad.getBounds().centerX(); + touchY -= dpad.getBounds().centerY(); + maxY -= dpad.getBounds().centerY(); + final float AxisX = touchX / maxX; + final float AxisY = touchY / maxY; + + boolean up = false; + boolean down = false; + boolean left = false; + boolean right = false; + if (EmulationMenuSettings.getDpadSlideEnable() || + (event.getAction() & MotionEvent.ACTION_MASK) == MotionEvent.ACTION_DOWN || + (event.getAction() & MotionEvent.ACTION_MASK) == MotionEvent.ACTION_POINTER_DOWN) { + if (AxisY < -InputOverlayDrawableDpad.VIRT_AXIS_DEADZONE) { + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getId(0), + NativeLibrary.ButtonState.PRESSED); + up = true; + } else { + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getId(0), + NativeLibrary.ButtonState.RELEASED); + } + if (AxisY > InputOverlayDrawableDpad.VIRT_AXIS_DEADZONE) { + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getId(1), + NativeLibrary.ButtonState.PRESSED); + down = true; + } else { + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getId(1), + NativeLibrary.ButtonState.RELEASED); + } + if (AxisX < -InputOverlayDrawableDpad.VIRT_AXIS_DEADZONE) { + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getId(2), + NativeLibrary.ButtonState.PRESSED); + left = true; + } else { + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getId(2), + NativeLibrary.ButtonState.RELEASED); + } + if (AxisX > InputOverlayDrawableDpad.VIRT_AXIS_DEADZONE) { + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getId(3), + NativeLibrary.ButtonState.PRESSED); + right = true; + } else { + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getId(3), + NativeLibrary.ButtonState.RELEASED); + } + + // Set state + if (up) { + if (left) + dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_UP_LEFT); + else if (right) + dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_UP_RIGHT); + else + dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_UP); + } else if (down) { + if (left) + dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_DOWN_LEFT); + else if (right) + dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_DOWN_RIGHT); + else + dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_DOWN); + } else if (left) { + dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_LEFT); + } else if (right) { + dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_RIGHT); + } else { + dpad.setState(InputOverlayDrawableDpad.STATE_DEFAULT); + } + } + } + } + } + } + + for (InputOverlayDrawableJoystick joystick : overlayJoysticks) { + joystick.TrackEvent(event); + int axisID = joystick.getId(); + float[] axises = joystick.getAxisValues(); + + NativeLibrary + .onGamePadMoveEvent(NativeLibrary.TouchScreenDevice, axisID, axises[0], axises[1]); + } + + invalidate(); + + return true; + } + + public boolean onTouchWhileEditing(MotionEvent event) { + int pointerIndex = event.getActionIndex(); + int fingerPositionX = (int) event.getX(pointerIndex); + int fingerPositionY = (int) event.getY(pointerIndex); + + String orientation = + getResources().getConfiguration().orientation == Configuration.ORIENTATION_PORTRAIT ? + "-Portrait" : ""; + + // Maybe combine Button and Joystick as subclasses of the same parent? + // Or maybe create an interface like IMoveableHUDControl? + + for (InputOverlayDrawableButton button : overlayButtons) { + // Determine the button state to apply based on the MotionEvent action flag. + switch (event.getAction() & MotionEvent.ACTION_MASK) { + case MotionEvent.ACTION_DOWN: + case MotionEvent.ACTION_POINTER_DOWN: + // If no button is being moved now, remember the currently touched button to move. + if (mButtonBeingConfigured == null && + button.getBounds().contains(fingerPositionX, fingerPositionY)) { + mButtonBeingConfigured = button; + mButtonBeingConfigured.onConfigureTouch(event); + } + break; + case MotionEvent.ACTION_MOVE: + if (mButtonBeingConfigured != null) { + mButtonBeingConfigured.onConfigureTouch(event); + invalidate(); + return true; + } + break; + + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_POINTER_UP: + if (mButtonBeingConfigured == button) { + // Persist button position by saving new place. + saveControlPosition(mButtonBeingConfigured.getId(), + mButtonBeingConfigured.getBounds().left, + mButtonBeingConfigured.getBounds().top, orientation); + mButtonBeingConfigured = null; + } + break; + } + } + + for (InputOverlayDrawableDpad dpad : overlayDpads) { + // Determine the button state to apply based on the MotionEvent action flag. + switch (event.getAction() & MotionEvent.ACTION_MASK) { + case MotionEvent.ACTION_DOWN: + case MotionEvent.ACTION_POINTER_DOWN: + // If no button is being moved now, remember the currently touched button to move. + if (mButtonBeingConfigured == null && + dpad.getBounds().contains(fingerPositionX, fingerPositionY)) { + mDpadBeingConfigured = dpad; + mDpadBeingConfigured.onConfigureTouch(event); + } + break; + case MotionEvent.ACTION_MOVE: + if (mDpadBeingConfigured != null) { + mDpadBeingConfigured.onConfigureTouch(event); + invalidate(); + return true; + } + break; + + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_POINTER_UP: + if (mDpadBeingConfigured == dpad) { + // Persist button position by saving new place. + saveControlPosition(mDpadBeingConfigured.getId(0), + mDpadBeingConfigured.getBounds().left, mDpadBeingConfigured.getBounds().top, + orientation); + mDpadBeingConfigured = null; + } + break; + } + } + + for (InputOverlayDrawableJoystick joystick : overlayJoysticks) { + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + case MotionEvent.ACTION_POINTER_DOWN: + if (mJoystickBeingConfigured == null && + joystick.getBounds().contains(fingerPositionX, fingerPositionY)) { + mJoystickBeingConfigured = joystick; + mJoystickBeingConfigured.onConfigureTouch(event); + } + break; + case MotionEvent.ACTION_MOVE: + if (mJoystickBeingConfigured != null) { + mJoystickBeingConfigured.onConfigureTouch(event); + invalidate(); + } + break; + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_POINTER_UP: + if (mJoystickBeingConfigured != null) { + saveControlPosition(mJoystickBeingConfigured.getId(), + mJoystickBeingConfigured.getBounds().left, + mJoystickBeingConfigured.getBounds().top, orientation); + mJoystickBeingConfigured = null; + } + break; + } + } + + return true; + } + + private void setDpadState(InputOverlayDrawableDpad dpad, boolean up, boolean down, boolean left, + boolean right) { + if (up) { + if (left) + dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_UP_LEFT); + else if (right) + dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_UP_RIGHT); + else + dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_UP); + } else if (down) { + if (left) + dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_DOWN_LEFT); + else if (right) + dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_DOWN_RIGHT); + else + dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_DOWN); + } else if (left) { + dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_LEFT); + } else if (right) { + dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_RIGHT); + } + } + + private void addOverlayControls(String orientation) { + if (mPreferences.getBoolean("buttonToggle0", true)) { + overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_a, + R.drawable.button_a_pressed, ButtonType.BUTTON_A, orientation)); + } + if (mPreferences.getBoolean("buttonToggle1", true)) { + overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_b, + R.drawable.button_b_pressed, ButtonType.BUTTON_B, orientation)); + } + if (mPreferences.getBoolean("buttonToggle2", true)) { + overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_x, + R.drawable.button_x_pressed, ButtonType.BUTTON_X, orientation)); + } + if (mPreferences.getBoolean("buttonToggle3", true)) { + overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_y, + R.drawable.button_y_pressed, ButtonType.BUTTON_Y, orientation)); + } + if (mPreferences.getBoolean("buttonToggle4", true)) { + overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_l, + R.drawable.button_l_pressed, ButtonType.TRIGGER_L, orientation)); + } + if (mPreferences.getBoolean("buttonToggle5", true)) { + overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_r, + R.drawable.button_r_pressed, ButtonType.TRIGGER_R, orientation)); + } + if (mPreferences.getBoolean("buttonToggle6", false)) { + overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_zl, + R.drawable.button_zl_pressed, ButtonType.BUTTON_ZL, orientation)); + } + if (mPreferences.getBoolean("buttonToggle7", false)) { + overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_zr, + R.drawable.button_zr_pressed, ButtonType.BUTTON_ZR, orientation)); + } + if (mPreferences.getBoolean("buttonToggle8", true)) { + overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_start, + R.drawable.button_start_pressed, ButtonType.BUTTON_START, orientation)); + } + if (mPreferences.getBoolean("buttonToggle9", true)) { + overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_select, + R.drawable.button_select_pressed, ButtonType.BUTTON_SELECT, orientation)); + } + if (mPreferences.getBoolean("buttonToggle10", true)) { + overlayDpads.add(initializeOverlayDpad(getContext(), R.drawable.dpad, + R.drawable.dpad_pressed_one_direction, + R.drawable.dpad_pressed_two_directions, + ButtonType.DPAD_UP, ButtonType.DPAD_DOWN, + ButtonType.DPAD_LEFT, ButtonType.DPAD_RIGHT, orientation)); + } + if (mPreferences.getBoolean("buttonToggle11", true)) { + overlayJoysticks.add(initializeOverlayJoystick(getContext(), R.drawable.stick_main_range, + R.drawable.stick_main, R.drawable.stick_main_pressed, + ButtonType.STICK_LEFT, orientation)); + } + if (mPreferences.getBoolean("buttonToggle12", false)) { + overlayJoysticks.add(initializeOverlayJoystick(getContext(), R.drawable.stick_c_range, + R.drawable.stick_c, R.drawable.stick_c_pressed, ButtonType.STICK_C, orientation)); + } + } + + public void refreshControls() { + // Remove all the overlay buttons from the HashSet. + overlayButtons.clear(); + overlayDpads.clear(); + overlayJoysticks.clear(); + + String orientation = + getResources().getConfiguration().orientation == Configuration.ORIENTATION_PORTRAIT ? + "-Portrait" : ""; + + // Add all the enabled overlay items back to the HashSet. + if (EmulationMenuSettings.getShowOverlay()) { + addOverlayControls(orientation); + } + + invalidate(); + } + + private void saveControlPosition(int sharedPrefsId, int x, int y, String orientation) { + final SharedPreferences sPrefs = PreferenceManager.getDefaultSharedPreferences(getContext()); + SharedPreferences.Editor sPrefsEditor = sPrefs.edit(); + sPrefsEditor.putFloat(sharedPrefsId + orientation + "-X", x); + sPrefsEditor.putFloat(sharedPrefsId + orientation + "-Y", y); + sPrefsEditor.apply(); + } + + public void setIsInEditMode(boolean isInEditMode) { + mIsInEditMode = isInEditMode; + } + + private void defaultOverlay() { + if (!mPreferences.getBoolean("OverlayInit", false)) { + // It's possible that a user has created their overlay before this was added + // Only change the overlay if the 'A' button is not in the upper corner. + if (mPreferences.getFloat(ButtonType.BUTTON_A + "-X", 0f) == 0f) { + defaultOverlayLandscape(); + } + if (mPreferences.getFloat(ButtonType.BUTTON_A + "-Portrait" + "-X", 0f) == 0f) { + defaultOverlayPortrait(); + } + } + + SharedPreferences.Editor sPrefsEditor = mPreferences.edit(); + sPrefsEditor.putBoolean("OverlayInit", true); + sPrefsEditor.apply(); + } + + public void resetButtonPlacement() { + boolean isLandscape = + getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE; + + if (isLandscape) { + defaultOverlayLandscape(); + } else { + defaultOverlayPortrait(); + } + + refreshControls(); + } + + private void defaultOverlayLandscape() { + SharedPreferences.Editor sPrefsEditor = mPreferences.edit(); + // Get screen size + Display display = ((Activity) getContext()).getWindowManager().getDefaultDisplay(); + DisplayMetrics outMetrics = new DisplayMetrics(); + display.getMetrics(outMetrics); + float maxX = outMetrics.heightPixels; + float maxY = outMetrics.widthPixels; + // Height and width changes depending on orientation. Use the larger value for height. + if (maxY > maxX) { + float tmp = maxX; + maxX = maxY; + maxY = tmp; + } + Resources res = getResources(); + + // Each value is a percent from max X/Y stored as an int. Have to bring that value down + // to a decimal before multiplying by MAX X/Y. + sPrefsEditor.putFloat(ButtonType.BUTTON_A + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_A_X) / 1000) * maxX)); + sPrefsEditor.putFloat(ButtonType.BUTTON_A + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_A_Y) / 1000) * maxY)); + sPrefsEditor.putFloat(ButtonType.BUTTON_B + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_B_X) / 1000) * maxX)); + sPrefsEditor.putFloat(ButtonType.BUTTON_B + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_B_Y) / 1000) * maxY)); + sPrefsEditor.putFloat(ButtonType.BUTTON_X + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_X_X) / 1000) * maxX)); + sPrefsEditor.putFloat(ButtonType.BUTTON_X + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_X_Y) / 1000) * maxY)); + sPrefsEditor.putFloat(ButtonType.BUTTON_Y + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_Y_X) / 1000) * maxX)); + sPrefsEditor.putFloat(ButtonType.BUTTON_Y + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_Y_Y) / 1000) * maxY)); + sPrefsEditor.putFloat(ButtonType.BUTTON_ZL + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_ZL_X) / 1000) * maxX)); + sPrefsEditor.putFloat(ButtonType.BUTTON_ZL + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_ZL_Y) / 1000) * maxY)); + sPrefsEditor.putFloat(ButtonType.BUTTON_ZR + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_ZR_X) / 1000) * maxX)); + sPrefsEditor.putFloat(ButtonType.BUTTON_ZR + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_ZR_Y) / 1000) * maxY)); + sPrefsEditor.putFloat(ButtonType.DPAD_UP + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_UP_X) / 1000) * maxX)); + sPrefsEditor.putFloat(ButtonType.DPAD_UP + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_UP_Y) / 1000) * maxY)); + sPrefsEditor.putFloat(ButtonType.TRIGGER_L + "-X", (((float) res.getInteger(R.integer.N3DS_TRIGGER_L_X) / 1000) * maxX)); + sPrefsEditor.putFloat(ButtonType.TRIGGER_L + "-Y", (((float) res.getInteger(R.integer.N3DS_TRIGGER_L_Y) / 1000) * maxY)); + sPrefsEditor.putFloat(ButtonType.TRIGGER_R + "-X", (((float) res.getInteger(R.integer.N3DS_TRIGGER_R_X) / 1000) * maxX)); + sPrefsEditor.putFloat(ButtonType.TRIGGER_R + "-Y", (((float) res.getInteger(R.integer.N3DS_TRIGGER_R_Y) / 1000) * maxY)); + sPrefsEditor.putFloat(ButtonType.BUTTON_START + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_START_X) / 1000) * maxX)); + sPrefsEditor.putFloat(ButtonType.BUTTON_START + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_START_Y) / 1000) * maxY)); + sPrefsEditor.putFloat(ButtonType.BUTTON_SELECT + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_SELECT_X) / 1000) * maxX)); + sPrefsEditor.putFloat(ButtonType.BUTTON_SELECT + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_SELECT_Y) / 1000) * maxY)); + sPrefsEditor.putFloat(ButtonType.BUTTON_HOME + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_HOME_X) / 1000) * maxX)); + sPrefsEditor.putFloat(ButtonType.BUTTON_HOME + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_HOME_Y) / 1000) * maxY)); + sPrefsEditor.putFloat(ButtonType.STICK_C + "-X", (((float) res.getInteger(R.integer.N3DS_STICK_C_X) / 1000) * maxX)); + sPrefsEditor.putFloat(ButtonType.STICK_C + "-Y", (((float) res.getInteger(R.integer.N3DS_STICK_C_Y) / 1000) * maxY)); + sPrefsEditor.putFloat(ButtonType.STICK_LEFT + "-X", (((float) res.getInteger(R.integer.N3DS_STICK_MAIN_X) / 1000) * maxX)); + sPrefsEditor.putFloat(ButtonType.STICK_LEFT + "-Y", (((float) res.getInteger(R.integer.N3DS_STICK_MAIN_Y) / 1000) * maxY)); + + // We want to commit right away, otherwise the overlay could load before this is saved. + sPrefsEditor.commit(); + } + + private void defaultOverlayPortrait() { + SharedPreferences.Editor sPrefsEditor = mPreferences.edit(); + // Get screen size + Display display = ((Activity) getContext()).getWindowManager().getDefaultDisplay(); + DisplayMetrics outMetrics = new DisplayMetrics(); + display.getMetrics(outMetrics); + float maxX = outMetrics.heightPixels; + float maxY = outMetrics.widthPixels; + // Height and width changes depending on orientation. Use the larger value for height. + if (maxY < maxX) { + float tmp = maxX; + maxX = maxY; + maxY = tmp; + } + Resources res = getResources(); + String portrait = "-Portrait"; + + // Each value is a percent from max X/Y stored as an int. Have to bring that value down + // to a decimal before multiplying by MAX X/Y. + sPrefsEditor.putFloat(ButtonType.BUTTON_A + portrait + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_A_PORTRAIT_X) / 1000) * maxX)); + sPrefsEditor.putFloat(ButtonType.BUTTON_A + portrait + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_A_PORTRAIT_Y) / 1000) * maxY)); + sPrefsEditor.putFloat(ButtonType.BUTTON_B + portrait + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_B_PORTRAIT_X) / 1000) * maxX)); + sPrefsEditor.putFloat(ButtonType.BUTTON_B + portrait + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_B_PORTRAIT_Y) / 1000) * maxY)); + sPrefsEditor.putFloat(ButtonType.BUTTON_X + portrait + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_X_PORTRAIT_X) / 1000) * maxX)); + sPrefsEditor.putFloat(ButtonType.BUTTON_X + portrait + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_X_PORTRAIT_Y) / 1000) * maxY)); + sPrefsEditor.putFloat(ButtonType.BUTTON_Y + portrait + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_Y_PORTRAIT_X) / 1000) * maxX)); + sPrefsEditor.putFloat(ButtonType.BUTTON_Y + portrait + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_Y_PORTRAIT_Y) / 1000) * maxY)); + sPrefsEditor.putFloat(ButtonType.BUTTON_ZL + portrait + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_ZL_PORTRAIT_X) / 1000) * maxX)); + sPrefsEditor.putFloat(ButtonType.BUTTON_ZL + portrait + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_ZL_PORTRAIT_Y) / 1000) * maxY)); + sPrefsEditor.putFloat(ButtonType.BUTTON_ZR + portrait + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_ZR_PORTRAIT_X) / 1000) * maxX)); + sPrefsEditor.putFloat(ButtonType.BUTTON_ZR + portrait + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_ZR_PORTRAIT_Y) / 1000) * maxY)); + sPrefsEditor.putFloat(ButtonType.DPAD_UP + portrait + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_UP_PORTRAIT_X) / 1000) * maxX)); + sPrefsEditor.putFloat(ButtonType.DPAD_UP + portrait + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_UP_PORTRAIT_Y) / 1000) * maxY)); + sPrefsEditor.putFloat(ButtonType.TRIGGER_L + portrait + "-X", (((float) res.getInteger(R.integer.N3DS_TRIGGER_L_PORTRAIT_X) / 1000) * maxX)); + sPrefsEditor.putFloat(ButtonType.TRIGGER_L + portrait + "-Y", (((float) res.getInteger(R.integer.N3DS_TRIGGER_L_PORTRAIT_Y) / 1000) * maxY)); + sPrefsEditor.putFloat(ButtonType.TRIGGER_R + portrait + "-X", (((float) res.getInteger(R.integer.N3DS_TRIGGER_R_PORTRAIT_X) / 1000) * maxX)); + sPrefsEditor.putFloat(ButtonType.TRIGGER_R + portrait + "-Y", (((float) res.getInteger(R.integer.N3DS_TRIGGER_R_PORTRAIT_Y) / 1000) * maxY)); + sPrefsEditor.putFloat(ButtonType.BUTTON_START + portrait + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_START_PORTRAIT_X) / 1000) * maxX)); + sPrefsEditor.putFloat(ButtonType.BUTTON_START + portrait + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_START_PORTRAIT_Y) / 1000) * maxY)); + sPrefsEditor.putFloat(ButtonType.BUTTON_SELECT + portrait + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_SELECT_PORTRAIT_X) / 1000) * maxX)); + sPrefsEditor.putFloat(ButtonType.BUTTON_SELECT + portrait + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_SELECT_PORTRAIT_Y) / 1000) * maxY)); + sPrefsEditor.putFloat(ButtonType.BUTTON_HOME + portrait + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_HOME_PORTRAIT_X) / 1000) * maxX)); + sPrefsEditor.putFloat(ButtonType.BUTTON_HOME + portrait + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_HOME_PORTRAIT_Y) / 1000) * maxY)); + sPrefsEditor.putFloat(ButtonType.STICK_C + portrait + "-X", (((float) res.getInteger(R.integer.N3DS_STICK_C_PORTRAIT_X) / 1000) * maxX)); + sPrefsEditor.putFloat(ButtonType.STICK_C + portrait + "-Y", (((float) res.getInteger(R.integer.N3DS_STICK_C_PORTRAIT_Y) / 1000) * maxY)); + sPrefsEditor.putFloat(ButtonType.STICK_LEFT + portrait + "-X", (((float) res.getInteger(R.integer.N3DS_STICK_MAIN_PORTRAIT_X) / 1000) * maxX)); + sPrefsEditor.putFloat(ButtonType.STICK_LEFT + portrait + "-Y", (((float) res.getInteger(R.integer.N3DS_STICK_MAIN_PORTRAIT_Y) / 1000) * maxY)); + + // We want to commit right away, otherwise the overlay could load before this is saved. + sPrefsEditor.commit(); + } + + public boolean isInEditMode() { + return mIsInEditMode; + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlayDrawableButton.java b/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlayDrawableButton.java new file mode 100644 index 000000000..81352296c --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlayDrawableButton.java @@ -0,0 +1,122 @@ +/** + * Copyright 2013 Dolphin Emulator Project + * Licensed under GPLv2+ + * Refer to the license.txt file included. + */ + +package org.citra.citra_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; + +/** + * Custom {@link BitmapDrawable} that is capable + * of storing it's own ID. + */ +public final class InputOverlayDrawableButton { + // The ID identifying what type of button this Drawable represents. + private int mButtonType; + private int mTrackId; + private int mPreviousTouchX, mPreviousTouchY; + private int mControlPositionX, mControlPositionY; + private int mWidth; + private int mHeight; + private BitmapDrawable mDefaultStateBitmap; + private BitmapDrawable mPressedStateBitmap; + private boolean mPressedState = false; + + /** + * Constructor + * + * @param res {@link Resources} instance. + * @param defaultStateBitmap {@link Bitmap} to use with the default state Drawable. + * @param pressedStateBitmap {@link Bitmap} to use with the pressed state Drawable. + * @param buttonType Identifier for this type of button. + */ + public InputOverlayDrawableButton(Resources res, Bitmap defaultStateBitmap, + Bitmap pressedStateBitmap, int buttonType) { + mDefaultStateBitmap = new BitmapDrawable(res, defaultStateBitmap); + mPressedStateBitmap = new BitmapDrawable(res, pressedStateBitmap); + mButtonType = buttonType; + + mWidth = mDefaultStateBitmap.getIntrinsicWidth(); + mHeight = mDefaultStateBitmap.getIntrinsicHeight(); + } + + /** + * Gets this InputOverlayDrawableButton's button ID. + * + * @return this InputOverlayDrawableButton's button ID. + */ + public int getId() { + return mButtonType; + } + + public int getTrackId() { + return mTrackId; + } + + public void setTrackId(int trackId) { + mTrackId = trackId; + } + + public boolean onConfigureTouch(MotionEvent event) { + int pointerIndex = event.getActionIndex(); + int fingerPositionX = (int) event.getX(pointerIndex); + int fingerPositionY = (int) event.getY(pointerIndex); + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + mPreviousTouchX = fingerPositionX; + mPreviousTouchY = fingerPositionY; + break; + case MotionEvent.ACTION_MOVE: + mControlPositionX += fingerPositionX - mPreviousTouchX; + mControlPositionY += fingerPositionY - mPreviousTouchY; + setBounds(mControlPositionX, mControlPositionY, getWidth() + mControlPositionX, + getHeight() + mControlPositionY); + mPreviousTouchX = fingerPositionX; + mPreviousTouchY = fingerPositionY; + break; + + } + return true; + } + + public void setPosition(int x, int y) { + mControlPositionX = x; + mControlPositionY = y; + } + + public void draw(Canvas canvas) { + getCurrentStateBitmapDrawable().draw(canvas); + } + + private BitmapDrawable getCurrentStateBitmapDrawable() { + return mPressedState ? mPressedStateBitmap : mDefaultStateBitmap; + } + + public void setBounds(int left, int top, int right, int bottom) { + mDefaultStateBitmap.setBounds(left, top, right, bottom); + mPressedStateBitmap.setBounds(left, top, right, bottom); + } + + public Rect getBounds() { + return mDefaultStateBitmap.getBounds(); + } + + public int getWidth() { + return mWidth; + } + + public int getHeight() { + return mHeight; + } + + public void setPressedState(boolean isPressed) { + mPressedState = isPressed; + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlayDrawableDpad.java b/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlayDrawableDpad.java new file mode 100644 index 000000000..87f3b7cd9 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlayDrawableDpad.java @@ -0,0 +1,193 @@ +/** + * Copyright 2016 Dolphin Emulator Project + * Licensed under GPLv2+ + * Refer to the license.txt file included. + */ + +package org.citra.citra_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; + +/** + * Custom {@link BitmapDrawable} that is capable + * of storing it's own ID. + */ +public final class InputOverlayDrawableDpad { + public static final int STATE_DEFAULT = 0; + public static final int STATE_PRESSED_UP = 1; + public static final int STATE_PRESSED_DOWN = 2; + public static final int STATE_PRESSED_LEFT = 3; + public static final int STATE_PRESSED_RIGHT = 4; + public static final int STATE_PRESSED_UP_LEFT = 5; + public static final int STATE_PRESSED_UP_RIGHT = 6; + public static final int STATE_PRESSED_DOWN_LEFT = 7; + public static final int STATE_PRESSED_DOWN_RIGHT = 8; + public static final float VIRT_AXIS_DEADZONE = 0.5f; + // The ID identifying what type of button this Drawable represents. + private int[] mButtonType = new int[4]; + private int mTrackId; + private int mPreviousTouchX, mPreviousTouchY; + private int mControlPositionX, mControlPositionY; + private int mWidth; + private int mHeight; + private BitmapDrawable mDefaultStateBitmap; + private BitmapDrawable mPressedOneDirectionStateBitmap; + private BitmapDrawable mPressedTwoDirectionsStateBitmap; + private int mPressState = STATE_DEFAULT; + + /** + * Constructor + * + * @param res {@link Resources} instance. + * @param defaultStateBitmap {@link Bitmap} of the default state. + * @param pressedOneDirectionStateBitmap {@link Bitmap} of the pressed state in one direction. + * @param pressedTwoDirectionsStateBitmap {@link 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. + */ + public InputOverlayDrawableDpad(Resources res, + Bitmap defaultStateBitmap, + Bitmap pressedOneDirectionStateBitmap, + Bitmap pressedTwoDirectionsStateBitmap, + int buttonUp, int buttonDown, + int buttonLeft, int buttonRight) { + mDefaultStateBitmap = new BitmapDrawable(res, defaultStateBitmap); + mPressedOneDirectionStateBitmap = new BitmapDrawable(res, pressedOneDirectionStateBitmap); + mPressedTwoDirectionsStateBitmap = new BitmapDrawable(res, pressedTwoDirectionsStateBitmap); + + mWidth = mDefaultStateBitmap.getIntrinsicWidth(); + mHeight = mDefaultStateBitmap.getIntrinsicHeight(); + + mButtonType[0] = buttonUp; + mButtonType[1] = buttonDown; + mButtonType[2] = buttonLeft; + mButtonType[3] = buttonRight; + + mTrackId = -1; + } + + public void draw(Canvas canvas) { + int px = mControlPositionX + (getWidth() / 2); + int py = mControlPositionY + (getHeight() / 2); + switch (mPressState) { + case STATE_DEFAULT: + mDefaultStateBitmap.draw(canvas); + break; + case STATE_PRESSED_UP: + mPressedOneDirectionStateBitmap.draw(canvas); + break; + case STATE_PRESSED_RIGHT: + canvas.save(); + canvas.rotate(90, px, py); + mPressedOneDirectionStateBitmap.draw(canvas); + canvas.restore(); + break; + case STATE_PRESSED_DOWN: + canvas.save(); + canvas.rotate(180, px, py); + mPressedOneDirectionStateBitmap.draw(canvas); + canvas.restore(); + break; + case STATE_PRESSED_LEFT: + canvas.save(); + canvas.rotate(270, px, py); + mPressedOneDirectionStateBitmap.draw(canvas); + canvas.restore(); + break; + case STATE_PRESSED_UP_LEFT: + mPressedTwoDirectionsStateBitmap.draw(canvas); + break; + case STATE_PRESSED_UP_RIGHT: + canvas.save(); + canvas.rotate(90, px, py); + mPressedTwoDirectionsStateBitmap.draw(canvas); + canvas.restore(); + break; + case STATE_PRESSED_DOWN_RIGHT: + canvas.save(); + canvas.rotate(180, px, py); + mPressedTwoDirectionsStateBitmap.draw(canvas); + canvas.restore(); + break; + case STATE_PRESSED_DOWN_LEFT: + canvas.save(); + canvas.rotate(270, px, py); + mPressedTwoDirectionsStateBitmap.draw(canvas); + canvas.restore(); + break; + } + } + + /** + * Gets one of the InputOverlayDrawableDpad's button IDs. + * + * @return the requested InputOverlayDrawableDpad's button ID. + */ + public int getId(int direction) { + return mButtonType[direction]; + } + + public int getTrackId() { + return mTrackId; + } + + public void setTrackId(int trackId) { + mTrackId = trackId; + } + + public boolean onConfigureTouch(MotionEvent event) { + int pointerIndex = event.getActionIndex(); + int fingerPositionX = (int) event.getX(pointerIndex); + int fingerPositionY = (int) event.getY(pointerIndex); + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + mPreviousTouchX = fingerPositionX; + mPreviousTouchY = fingerPositionY; + break; + case MotionEvent.ACTION_MOVE: + mControlPositionX += fingerPositionX - mPreviousTouchX; + mControlPositionY += fingerPositionY - mPreviousTouchY; + setBounds(mControlPositionX, mControlPositionY, getWidth() + mControlPositionX, + getHeight() + mControlPositionY); + mPreviousTouchX = fingerPositionX; + mPreviousTouchY = fingerPositionY; + break; + + } + return true; + } + + public void setPosition(int x, int y) { + mControlPositionX = x; + mControlPositionY = y; + } + + public void setBounds(int left, int top, int right, int bottom) { + mDefaultStateBitmap.setBounds(left, top, right, bottom); + mPressedOneDirectionStateBitmap.setBounds(left, top, right, bottom); + mPressedTwoDirectionsStateBitmap.setBounds(left, top, right, bottom); + } + + public Rect getBounds() { + return mDefaultStateBitmap.getBounds(); + } + + public int getWidth() { + return mWidth; + } + + public int getHeight() { + return mHeight; + } + + public void setState(int pressState) { + mPressState = pressState; + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlayDrawableJoystick.java b/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlayDrawableJoystick.java new file mode 100644 index 000000000..956a8b1e9 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlayDrawableJoystick.java @@ -0,0 +1,264 @@ +/** + * Copyright 2013 Dolphin Emulator Project + * Licensed under GPLv2+ + * Refer to the license.txt file included. + */ + +package org.citra.citra_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.citra.citra_emu.NativeLibrary.ButtonType; +import org.citra.citra_emu.utils.EmulationMenuSettings; + +/** + * Custom {@link BitmapDrawable} that is capable + * of storing it's own ID. + */ +public final class InputOverlayDrawableJoystick { + private final int[] axisIDs = {0, 0, 0, 0}; + private final float[] axises = {0f, 0f}; + private int trackId = -1; + private int mJoystickType; + private int mControlPositionX, mControlPositionY; + private int mPreviousTouchX, mPreviousTouchY; + private int mWidth; + private int mHeight; + private Rect mVirtBounds; + private Rect mOrigBounds; + private BitmapDrawable mOuterBitmap; + private BitmapDrawable mDefaultStateInnerBitmap; + private BitmapDrawable mPressedStateInnerBitmap; + private BitmapDrawable mBoundsBoxBitmap; + private boolean mPressedState = false; + + /** + * Constructor + * + * @param res {@link Resources} instance. + * @param bitmapOuter {@link Bitmap} which represents the outer non-movable part of the joystick. + * @param bitmapInnerDefault {@link Bitmap} which represents the default inner movable part of the joystick. + * @param bitmapInnerPressed {@link Bitmap} which represents the pressed inner movable part of the joystick. + * @param rectOuter {@link Rect} which represents the outer joystick bounds. + * @param rectInner {@link Rect} which represents the inner joystick bounds. + * @param joystick Identifier for which joystick this is. + */ + public InputOverlayDrawableJoystick(Resources res, Bitmap bitmapOuter, + Bitmap bitmapInnerDefault, Bitmap bitmapInnerPressed, + Rect rectOuter, Rect rectInner, int joystick) { + axisIDs[0] = joystick + 1; // Up + axisIDs[1] = joystick + 2; // Down + axisIDs[2] = joystick + 3; // Left + axisIDs[3] = joystick + 4; // Right + mJoystickType = joystick; + + mOuterBitmap = new BitmapDrawable(res, bitmapOuter); + mDefaultStateInnerBitmap = new BitmapDrawable(res, bitmapInnerDefault); + mPressedStateInnerBitmap = new BitmapDrawable(res, bitmapInnerPressed); + mBoundsBoxBitmap = new BitmapDrawable(res, bitmapOuter); + mWidth = bitmapOuter.getWidth(); + mHeight = bitmapOuter.getHeight(); + + setBounds(rectOuter); + mDefaultStateInnerBitmap.setBounds(rectInner); + mPressedStateInnerBitmap.setBounds(rectInner); + mVirtBounds = getBounds(); + mOrigBounds = mOuterBitmap.copyBounds(); + mBoundsBoxBitmap.setAlpha(0); + mBoundsBoxBitmap.setBounds(getVirtBounds()); + SetInnerBounds(); + } + + /** + * Gets this InputOverlayDrawableJoystick's button ID. + * + * @return this InputOverlayDrawableJoystick's button ID. + */ + public int getId() { + return mJoystickType; + } + + public void draw(Canvas canvas) { + mOuterBitmap.draw(canvas); + getCurrentStateBitmapDrawable().draw(canvas); + mBoundsBoxBitmap.draw(canvas); + } + + public void TrackEvent(MotionEvent event) { + int pointerIndex = event.getActionIndex(); + + switch (event.getAction() & MotionEvent.ACTION_MASK) { + case MotionEvent.ACTION_DOWN: + case MotionEvent.ACTION_POINTER_DOWN: + if (getBounds().contains((int) event.getX(pointerIndex), (int) event.getY(pointerIndex))) { + mPressedState = true; + mOuterBitmap.setAlpha(0); + mBoundsBoxBitmap.setAlpha(255); + if (EmulationMenuSettings.getJoystickRelCenter()) { + getVirtBounds().offset((int) event.getX(pointerIndex) - getVirtBounds().centerX(), + (int) event.getY(pointerIndex) - getVirtBounds().centerY()); + } + mBoundsBoxBitmap.setBounds(getVirtBounds()); + trackId = event.getPointerId(pointerIndex); + } + break; + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_POINTER_UP: + if (trackId == event.getPointerId(pointerIndex)) { + mPressedState = false; + axises[0] = axises[1] = 0.0f; + mOuterBitmap.setAlpha(255); + mBoundsBoxBitmap.setAlpha(0); + setVirtBounds(new Rect(mOrigBounds.left, mOrigBounds.top, mOrigBounds.right, + mOrigBounds.bottom)); + setBounds(new Rect(mOrigBounds.left, mOrigBounds.top, mOrigBounds.right, + mOrigBounds.bottom)); + SetInnerBounds(); + trackId = -1; + } + break; + } + + if (trackId == -1) + return; + + for (int i = 0; i < event.getPointerCount(); i++) { + if (trackId == event.getPointerId(i)) { + float touchX = event.getX(i); + float touchY = event.getY(i); + float maxY = getVirtBounds().bottom; + float maxX = getVirtBounds().right; + touchX -= getVirtBounds().centerX(); + maxX -= getVirtBounds().centerX(); + touchY -= getVirtBounds().centerY(); + maxY -= getVirtBounds().centerY(); + final float AxisX = touchX / maxX; + final float AxisY = touchY / maxY; + + // Clamp the circle pad input to a circle + final float angle = (float) Math.atan2(AxisY, AxisX); + float radius = (float) Math.sqrt(AxisX * AxisX + AxisY * AxisY); + if(radius > 1.0f) + { + radius = 1.0f; + } + axises[0] = ((float)Math.cos(angle) * radius); + axises[1] = ((float)Math.sin(angle) * radius); + SetInnerBounds(); + } + } + } + + public boolean onConfigureTouch(MotionEvent event) { + int pointerIndex = event.getActionIndex(); + int fingerPositionX = (int) event.getX(pointerIndex); + int fingerPositionY = (int) event.getY(pointerIndex); + + int scale = 1; + if (mJoystickType == ButtonType.STICK_C) { + // C-stick is scaled down to be half the size of the circle pad + scale = 2; + } + + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + mPreviousTouchX = fingerPositionX; + mPreviousTouchY = fingerPositionY; + break; + case MotionEvent.ACTION_MOVE: + int deltaX = fingerPositionX - mPreviousTouchX; + int deltaY = fingerPositionY - mPreviousTouchY; + mControlPositionX += deltaX; + mControlPositionY += deltaY; + setBounds(new Rect(mControlPositionX, mControlPositionY, + mOuterBitmap.getIntrinsicWidth() / scale + mControlPositionX, + mOuterBitmap.getIntrinsicHeight() / scale + mControlPositionY)); + setVirtBounds(new Rect(mControlPositionX, mControlPositionY, + mOuterBitmap.getIntrinsicWidth() / scale + mControlPositionX, + mOuterBitmap.getIntrinsicHeight() / scale + mControlPositionY)); + SetInnerBounds(); + setOrigBounds(new Rect(new Rect(mControlPositionX, mControlPositionY, + mOuterBitmap.getIntrinsicWidth() / scale + mControlPositionX, + mOuterBitmap.getIntrinsicHeight() / scale + mControlPositionY))); + mPreviousTouchX = fingerPositionX; + mPreviousTouchY = fingerPositionY; + break; + } + return true; + } + + + public float[] getAxisValues() { + return axises; + } + + public int[] getAxisIDs() { + return axisIDs; + } + + private void SetInnerBounds() { + int X = getVirtBounds().centerX() + (int) ((axises[0]) * (getVirtBounds().width() / 2)); + int Y = getVirtBounds().centerY() + (int) ((axises[1]) * (getVirtBounds().height() / 2)); + + if (mJoystickType == ButtonType.STICK_LEFT) { + X += 1; + Y += 1; + } + + if (X > getVirtBounds().centerX() + (getVirtBounds().width() / 2)) + X = getVirtBounds().centerX() + (getVirtBounds().width() / 2); + if (X < getVirtBounds().centerX() - (getVirtBounds().width() / 2)) + X = getVirtBounds().centerX() - (getVirtBounds().width() / 2); + if (Y > getVirtBounds().centerY() + (getVirtBounds().height() / 2)) + Y = getVirtBounds().centerY() + (getVirtBounds().height() / 2); + if (Y < getVirtBounds().centerY() - (getVirtBounds().height() / 2)) + Y = getVirtBounds().centerY() - (getVirtBounds().height() / 2); + + int width = mPressedStateInnerBitmap.getBounds().width() / 2; + int height = mPressedStateInnerBitmap.getBounds().height() / 2; + mDefaultStateInnerBitmap.setBounds(X - width, Y - height, X + width, Y + height); + mPressedStateInnerBitmap.setBounds(mDefaultStateInnerBitmap.getBounds()); + } + + public void setPosition(int x, int y) { + mControlPositionX = x; + mControlPositionY = y; + } + + private BitmapDrawable getCurrentStateBitmapDrawable() { + return mPressedState ? mPressedStateInnerBitmap : mDefaultStateInnerBitmap; + } + + public Rect getBounds() { + return mOuterBitmap.getBounds(); + } + + public void setBounds(Rect bounds) { + mOuterBitmap.setBounds(bounds); + } + + private void setOrigBounds(Rect bounds) { + mOrigBounds = bounds; + } + + private Rect getVirtBounds() { + return mVirtBounds; + } + + private void setVirtBounds(Rect bounds) { + mVirtBounds = bounds; + } + + public int getWidth() { + return mWidth; + } + + public int getHeight() { + return mHeight; + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/ui/DividerItemDecoration.java b/src/android/app/src/main/java/org/citra/citra_emu/ui/DividerItemDecoration.java new file mode 100644 index 000000000..96ccc08bb --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/ui/DividerItemDecoration.java @@ -0,0 +1,130 @@ +package org.citra.citra_emu.ui; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +/** + * Implementation from: + * https://gist.github.com/lapastillaroja/858caf1a82791b6c1a36 + */ +public class DividerItemDecoration extends RecyclerView.ItemDecoration { + + private Drawable mDivider; + private boolean mShowFirstDivider = false; + private boolean mShowLastDivider = false; + + public DividerItemDecoration(Context context, AttributeSet attrs) { + final TypedArray a = context + .obtainStyledAttributes(attrs, new int[]{android.R.attr.listDivider}); + mDivider = a.getDrawable(0); + a.recycle(); + } + + public DividerItemDecoration(Context context, AttributeSet attrs, boolean showFirstDivider, + boolean showLastDivider) { + this(context, attrs); + mShowFirstDivider = showFirstDivider; + mShowLastDivider = showLastDivider; + } + + public DividerItemDecoration(Drawable divider) { + mDivider = divider; + } + + public DividerItemDecoration(Drawable divider, boolean showFirstDivider, + boolean showLastDivider) { + this(divider); + mShowFirstDivider = showFirstDivider; + mShowLastDivider = showLastDivider; + } + + @Override + public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, + @NonNull RecyclerView.State state) { + super.getItemOffsets(outRect, view, parent, state); + if (mDivider == null) { + return; + } + if (parent.getChildAdapterPosition(view) < 1) { + return; + } + + if (getOrientation(parent) == LinearLayoutManager.VERTICAL) { + outRect.top = mDivider.getIntrinsicHeight(); + } else { + outRect.left = mDivider.getIntrinsicWidth(); + } + } + + @Override + public void onDrawOver(@NonNull Canvas c, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) { + if (mDivider == null) { + super.onDrawOver(c, parent, state); + return; + } + + // Initialization needed to avoid compiler warning + int left = 0, right = 0, top = 0, bottom = 0, size; + int orientation = getOrientation(parent); + int childCount = parent.getChildCount(); + + if (orientation == LinearLayoutManager.VERTICAL) { + size = mDivider.getIntrinsicHeight(); + left = parent.getPaddingLeft(); + right = parent.getWidth() - parent.getPaddingRight(); + } else { //horizontal + size = mDivider.getIntrinsicWidth(); + top = parent.getPaddingTop(); + bottom = parent.getHeight() - parent.getPaddingBottom(); + } + + for (int i = mShowFirstDivider ? 0 : 1; i < childCount; i++) { + View child = parent.getChildAt(i); + RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) child.getLayoutParams(); + + if (orientation == LinearLayoutManager.VERTICAL) { + top = child.getTop() - params.topMargin; + bottom = top + size; + } else { //horizontal + left = child.getLeft() - params.leftMargin; + right = left + size; + } + mDivider.setBounds(left, top, right, bottom); + mDivider.draw(c); + } + + // show last divider + if (mShowLastDivider && childCount > 0) { + View child = parent.getChildAt(childCount - 1); + RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) child.getLayoutParams(); + if (orientation == LinearLayoutManager.VERTICAL) { + top = child.getBottom() + params.bottomMargin; + bottom = top + size; + } else { // horizontal + left = child.getRight() + params.rightMargin; + right = left + size; + } + mDivider.setBounds(left, top, right, bottom); + mDivider.draw(c); + } + } + + private int getOrientation(RecyclerView parent) { + if (parent.getLayoutManager() instanceof LinearLayoutManager) { + LinearLayoutManager layoutManager = (LinearLayoutManager) parent.getLayoutManager(); + return layoutManager.getOrientation(); + } else { + throw new IllegalStateException( + "DividerItemDecoration can only be used with a LinearLayoutManager."); + } + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainActivity.java b/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainActivity.java new file mode 100644 index 000000000..402c8a4e0 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainActivity.java @@ -0,0 +1,269 @@ +package org.citra.citra_emu.ui.main; + +import android.content.Intent; +import android.content.pm.PackageManager; +import android.os.Bundle; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.widget.Toolbar; + +import org.citra.citra_emu.NativeLibrary; +import org.citra.citra_emu.R; +import org.citra.citra_emu.activities.EmulationActivity; +import org.citra.citra_emu.features.settings.ui.SettingsActivity; +import org.citra.citra_emu.model.GameProvider; +import org.citra.citra_emu.ui.platform.PlatformGamesFragment; +import org.citra.citra_emu.utils.AddDirectoryHelper; +import org.citra.citra_emu.utils.BillingManager; +import org.citra.citra_emu.utils.DirectoryInitialization; +import org.citra.citra_emu.utils.FileBrowserHelper; +import org.citra.citra_emu.utils.PermissionsHandler; +import org.citra.citra_emu.utils.PicassoUtils; +import org.citra.citra_emu.utils.StartupHandler; +import org.citra.citra_emu.utils.ThemeUtil; + +import java.util.Arrays; +import java.util.Collections; + +/** + * The main Activity of the Lollipop style UI. Manages several PlatformGamesFragments, which + * individually display a grid of available games for each Fragment, in a tabbed layout. + */ +public final class MainActivity extends AppCompatActivity implements MainView { + private Toolbar mToolbar; + private int mFrameLayoutId; + private PlatformGamesFragment mPlatformGamesFragment; + + private MainPresenter mPresenter = new MainPresenter(this); + + // Singleton to manage user billing state + private static BillingManager mBillingManager; + + private static MenuItem mPremiumButton; + + @Override + protected void onCreate(Bundle savedInstanceState) { + ThemeUtil.applyTheme(); + + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_main); + + findViews(); + + setSupportActionBar(mToolbar); + + mFrameLayoutId = R.id.games_platform_frame; + mPresenter.onCreate(); + + if (savedInstanceState == null) { + StartupHandler.HandleInit(this); + if (PermissionsHandler.hasWriteAccess(this)) { + mPlatformGamesFragment = new PlatformGamesFragment(); + getSupportFragmentManager().beginTransaction().add(mFrameLayoutId, mPlatformGamesFragment) + .commit(); + } + } else { + mPlatformGamesFragment = (PlatformGamesFragment) getSupportFragmentManager().getFragment(savedInstanceState, "mPlatformGamesFragment"); + } + PicassoUtils.init(); + + // Setup billing manager, so we can globally query for Premium status + mBillingManager = new BillingManager(this); + + // Dismiss previous notifications (should not happen unless a crash occurred) + EmulationActivity.tryDismissRunningNotification(this); + } + + @Override + protected void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + if (PermissionsHandler.hasWriteAccess(this)) { + if (getSupportFragmentManager() == null) { + return; + } + if (outState == null) { + return; + } + getSupportFragmentManager().putFragment(outState, "mPlatformGamesFragment", mPlatformGamesFragment); + } + } + + @Override + protected void onResume() { + super.onResume(); + mPresenter.addDirIfNeeded(new AddDirectoryHelper(this)); + } + + // TODO: Replace with a ButterKnife injection. + private void findViews() { + mToolbar = findViewById(R.id.toolbar_main); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + MenuInflater inflater = getMenuInflater(); + inflater.inflate(R.menu.menu_game_grid, menu); + mPremiumButton = menu.findItem(R.id.button_premium); + + if (mBillingManager.isPremiumCached()) { + // User had premium in a previous session, hide upsell option + setPremiumButtonVisible(false); + } + + return true; + } + + static public void setPremiumButtonVisible(boolean isVisible) { + if (mPremiumButton != null) { + mPremiumButton.setVisible(isVisible); + } + } + + /** + * MainView + */ + + @Override + public void setVersionString(String version) { + mToolbar.setSubtitle(version); + } + + @Override + public void refresh() { + getContentResolver().insert(GameProvider.URI_REFRESH, null); + refreshFragment(); + } + + @Override + public void launchSettingsActivity(String menuTag) { + if (PermissionsHandler.hasWriteAccess(this)) { + SettingsActivity.launch(this, menuTag, ""); + } else { + PermissionsHandler.checkWritePermission(this); + } + } + + @Override + public void launchFileListActivity(int request) { + if (PermissionsHandler.hasWriteAccess(this)) { + switch (request) { + case MainPresenter.REQUEST_ADD_DIRECTORY: + FileBrowserHelper.openDirectoryPicker(this, + MainPresenter.REQUEST_ADD_DIRECTORY, + R.string.select_game_folder, + Arrays.asList("elf", "axf", "cci", "3ds", + "cxi", "app", "3dsx", "cia", + "rar", "zip", "7z", "torrent", + "tar", "gz")); + break; + case MainPresenter.REQUEST_INSTALL_CIA: + FileBrowserHelper.openFilePicker(this, MainPresenter.REQUEST_INSTALL_CIA, + R.string.install_cia_title, + Collections.singletonList("cia"), true); + break; + } + } else { + PermissionsHandler.checkWritePermission(this); + } + } + + /** + * @param requestCode An int describing whether the Activity that is returning did so successfully. + * @param resultCode An int describing what Activity is giving us this callback. + * @param result The information the returning Activity is providing us. + */ + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent result) { + super.onActivityResult(requestCode, resultCode, result); + switch (requestCode) { + case MainPresenter.REQUEST_ADD_DIRECTORY: + // If the user picked a file, as opposed to just backing out. + if (resultCode == MainActivity.RESULT_OK) { + // When a new directory is picked, we currently will reset the existing games + // database. This effectively means that only one game directory is supported. + // TODO(bunnei): Consider fixing this in the future, or removing code for this. + getContentResolver().insert(GameProvider.URI_RESET, null); + // Add the new directory + mPresenter.onDirectorySelected(FileBrowserHelper.getSelectedDirectory(result)); + } + break; + case MainPresenter.REQUEST_INSTALL_CIA: + // If the user picked a file, as opposed to just backing out. + if (resultCode == MainActivity.RESULT_OK) { + NativeLibrary.InstallCIAS(FileBrowserHelper.getSelectedFiles(result)); + mPresenter.refeshGameList(); + } + break; + } + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + switch (requestCode) { + case PermissionsHandler.REQUEST_CODE_WRITE_PERMISSION: + if (grantResults[0] == PackageManager.PERMISSION_GRANTED) { + DirectoryInitialization.start(this); + + mPlatformGamesFragment = new PlatformGamesFragment(); + getSupportFragmentManager().beginTransaction().add(mFrameLayoutId, mPlatformGamesFragment) + .commit(); + + // Immediately prompt user to select a game directory on first boot + if (mPresenter != null) { + mPresenter.launchFileListActivity(MainPresenter.REQUEST_ADD_DIRECTORY); + } + } else { + Toast.makeText(this, R.string.write_permission_needed, Toast.LENGTH_SHORT) + .show(); + } + break; + default: + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + break; + } + } + + /** + * Called by the framework whenever any actionbar/toolbar icon is clicked. + * + * @param item The icon that was clicked on. + * @return True if the event was handled, false to bubble it up to the OS. + */ + @Override + public boolean onOptionsItemSelected(MenuItem item) { + return mPresenter.handleOptionSelection(item.getItemId()); + } + + private void refreshFragment() { + if (mPlatformGamesFragment != null) { + mPlatformGamesFragment.refresh(); + } + } + + @Override + protected void onDestroy() { + EmulationActivity.tryDismissRunningNotification(this); + super.onDestroy(); + } + + /** + * @return true if Premium subscription is currently active + */ + public static boolean isPremiumActive() { + return mBillingManager.isPremiumActive(); + } + + /** + * Invokes the billing flow for Premium + * + * @param callback Optional callback, called once, on completion of billing + */ + public static void invokePremiumBilling(Runnable callback) { + mBillingManager.invokePremiumBilling(callback); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainPresenter.java b/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainPresenter.java new file mode 100644 index 000000000..4e9994c2a --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainPresenter.java @@ -0,0 +1,82 @@ +package org.citra.citra_emu.ui.main; + +import android.os.SystemClock; + +import org.citra.citra_emu.BuildConfig; +import org.citra.citra_emu.CitraApplication; +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.settings.model.Settings; +import org.citra.citra_emu.features.settings.utils.SettingsFile; +import org.citra.citra_emu.model.GameDatabase; +import org.citra.citra_emu.utils.AddDirectoryHelper; + +public final class MainPresenter { + public static final int REQUEST_ADD_DIRECTORY = 1; + public static final int REQUEST_INSTALL_CIA = 2; + + private final MainView mView; + private String mDirToAdd; + private long mLastClickTime = 0; + + public MainPresenter(MainView view) { + mView = view; + } + + public void onCreate() { + String versionName = BuildConfig.VERSION_NAME; + mView.setVersionString(versionName); + refeshGameList(); + } + + public void launchFileListActivity(int request) { + if (mView != null) { + mView.launchFileListActivity(request); + } + } + + public boolean handleOptionSelection(int itemId) { + // Double-click prevention, using threshold of 500 ms + if (SystemClock.elapsedRealtime() - mLastClickTime < 500) { + return false; + } + mLastClickTime = SystemClock.elapsedRealtime(); + + switch (itemId) { + case R.id.menu_settings_core: + mView.launchSettingsActivity(SettingsFile.FILE_NAME_CONFIG); + return true; + + case R.id.button_add_directory: + launchFileListActivity(REQUEST_ADD_DIRECTORY); + return true; + + case R.id.button_install_cia: + launchFileListActivity(REQUEST_INSTALL_CIA); + return true; + + case R.id.button_premium: + mView.launchSettingsActivity(Settings.SECTION_PREMIUM); + return true; + } + + return false; + } + + public void addDirIfNeeded(AddDirectoryHelper helper) { + if (mDirToAdd != null) { + helper.addDirectory(mDirToAdd, mView::refresh); + + mDirToAdd = null; + } + } + + public void onDirectorySelected(String dir) { + mDirToAdd = dir; + } + + public void refeshGameList() { + GameDatabase databaseHelper = CitraApplication.databaseHelper; + databaseHelper.scanLibrary(databaseHelper.getWritableDatabase()); + mView.refresh(); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainView.java b/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainView.java new file mode 100644 index 000000000..de7c04875 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainView.java @@ -0,0 +1,25 @@ +package org.citra.citra_emu.ui.main; + +/** + * Abstraction for the screen that shows on application launch. + * Implementations will differ primarily to target touch-screen + * or non-touch screen devices. + */ +public interface MainView { + /** + * Pass the view the native library's version string. Displaying + * it is optional. + * + * @param version A string pulled from native code. + */ + void setVersionString(String version); + + /** + * Tell the view to refresh its contents. + */ + void refresh(); + + void launchSettingsActivity(String menuTag); + + void launchFileListActivity(int request); +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesFragment.java b/src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesFragment.java new file mode 100644 index 000000000..9fc30796f --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesFragment.java @@ -0,0 +1,86 @@ +package org.citra.citra_emu.ui.platform; + +import android.database.Cursor; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.core.content.ContextCompat; +import androidx.fragment.app.Fragment; +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; + +import org.citra.citra_emu.CitraApplication; +import org.citra.citra_emu.R; +import org.citra.citra_emu.adapters.GameAdapter; +import org.citra.citra_emu.model.GameDatabase; + +public final class PlatformGamesFragment extends Fragment implements PlatformGamesView { + private PlatformGamesPresenter mPresenter = new PlatformGamesPresenter(this); + + private GameAdapter mAdapter; + private RecyclerView mRecyclerView; + private TextView mTextView; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + View rootView = inflater.inflate(R.layout.fragment_grid, container, false); + + findViews(rootView); + + mPresenter.onCreateView(); + + return rootView; + } + + @Override + public void onViewCreated(View view, Bundle savedInstanceState) { + int columns = getResources().getInteger(R.integer.game_grid_columns); + RecyclerView.LayoutManager layoutManager = new GridLayoutManager(getActivity(), columns); + mAdapter = new GameAdapter(); + + mRecyclerView.setLayoutManager(layoutManager); + mRecyclerView.setAdapter(mAdapter); + mRecyclerView.addItemDecoration(new GameAdapter.SpacesItemDecoration(ContextCompat.getDrawable(getActivity(), R.drawable.gamelist_divider), 1)); + + // Add swipe down to refresh gesture + final SwipeRefreshLayout pullToRefresh = view.findViewById(R.id.refresh_grid_games); + pullToRefresh.setOnRefreshListener(() -> { + GameDatabase databaseHelper = CitraApplication.databaseHelper; + databaseHelper.scanLibrary(databaseHelper.getWritableDatabase()); + refresh(); + pullToRefresh.setRefreshing(false); + }); + } + + @Override + public void refresh() { + mPresenter.refresh(); + updateTextView(); + } + + @Override + public void showGames(Cursor games) { + if (mAdapter != null) { + mAdapter.swapCursor(games); + } + updateTextView(); + } + + private void updateTextView() { + mTextView.setVisibility(mAdapter.getItemCount() == 0 ? View.VISIBLE : View.GONE); + } + + private void findViews(View root) { + mRecyclerView = root.findViewById(R.id.grid_games); + mTextView = root.findViewById(R.id.gamelist_empty_text); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesPresenter.java b/src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesPresenter.java new file mode 100644 index 000000000..9d8040e1b --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesPresenter.java @@ -0,0 +1,42 @@ +package org.citra.citra_emu.ui.platform; + + +import org.citra.citra_emu.CitraApplication; +import org.citra.citra_emu.model.GameDatabase; +import org.citra.citra_emu.utils.Log; + +import rx.android.schedulers.AndroidSchedulers; +import rx.schedulers.Schedulers; + +public final class PlatformGamesPresenter { + private final PlatformGamesView mView; + + public PlatformGamesPresenter(PlatformGamesView view) { + mView = view; + } + + public void onCreateView() { + loadGames(); + } + + public void refresh() { + Log.debug("[PlatformGamesPresenter] : Refreshing..."); + loadGames(); + } + + private void loadGames() { + Log.debug("[PlatformGamesPresenter] : Loading games..."); + + GameDatabase databaseHelper = CitraApplication.databaseHelper; + + databaseHelper.getGames() + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(games -> + { + Log.debug("[PlatformGamesPresenter] : Load finished, swapping cursor..."); + + mView.showGames(games); + }); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesView.java b/src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesView.java new file mode 100644 index 000000000..4332121eb --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesView.java @@ -0,0 +1,21 @@ +package org.citra.citra_emu.ui.platform; + +import android.database.Cursor; + +/** + * Abstraction for a screen representing a single platform's games. + */ +public interface PlatformGamesView { + /** + * Tell the view to refresh its contents. + */ + void refresh(); + + /** + * To be called when an asynchronous database read completes. Passes the + * result, in this case a {@link Cursor}, to the view. + * + * @param games A Cursor containing the games read from the database. + */ + void showGames(Cursor games); +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/Action1.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/Action1.java new file mode 100644 index 000000000..886846ec5 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/Action1.java @@ -0,0 +1,5 @@ +package org.citra.citra_emu.utils; + +public interface Action1 { + void call(T t); +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/AddDirectoryHelper.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/AddDirectoryHelper.java new file mode 100644 index 000000000..7578c353f --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/AddDirectoryHelper.java @@ -0,0 +1,38 @@ +package org.citra.citra_emu.utils; + +import android.content.AsyncQueryHandler; +import android.content.ContentValues; +import android.content.Context; +import android.net.Uri; + +import org.citra.citra_emu.model.GameDatabase; +import org.citra.citra_emu.model.GameProvider; + +public class AddDirectoryHelper { + private Context mContext; + + public AddDirectoryHelper(Context context) { + this.mContext = context; + } + + public void addDirectory(String dir, AddDirectoryListener addDirectoryListener) { + AsyncQueryHandler handler = new AsyncQueryHandler(mContext.getContentResolver()) { + @Override + protected void onInsertComplete(int token, Object cookie, Uri uri) { + addDirectoryListener.onDirectoryAdded(); + } + }; + + ContentValues file = new ContentValues(); + file.put(GameDatabase.KEY_FOLDER_PATH, dir); + + handler.startInsert(0, // We don't need to identify this call to the handler + null, // We don't need to pass additional data to the handler + GameProvider.URI_FOLDER, // Tell the GameProvider we are adding a folder + file); + } + + public interface AddDirectoryListener { + void onDirectoryAdded(); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/BiMap.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/BiMap.java new file mode 100644 index 000000000..dfbab1780 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/BiMap.java @@ -0,0 +1,22 @@ +package org.citra.citra_emu.utils; + +import java.util.HashMap; +import java.util.Map; + +public class BiMap { + private Map forward = new HashMap(); + private Map backward = new HashMap(); + + public synchronized void add(K key, V value) { + forward.put(key, value); + backward.put(value, key); + } + + public synchronized V getForward(K key) { + return forward.get(key); + } + + public synchronized K getBackward(V key) { + return backward.get(key); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/BillingManager.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/BillingManager.java new file mode 100644 index 000000000..5dc54c235 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/BillingManager.java @@ -0,0 +1,215 @@ +package org.citra.citra_emu.utils; + +import android.app.Activity; +import android.content.SharedPreferences; +import android.preference.PreferenceManager; +import android.widget.Toast; + +import com.android.billingclient.api.AcknowledgePurchaseParams; +import com.android.billingclient.api.AcknowledgePurchaseResponseListener; +import com.android.billingclient.api.BillingClient; +import com.android.billingclient.api.BillingClientStateListener; +import com.android.billingclient.api.BillingFlowParams; +import com.android.billingclient.api.BillingResult; +import com.android.billingclient.api.Purchase; +import com.android.billingclient.api.Purchase.PurchasesResult; +import com.android.billingclient.api.PurchasesUpdatedListener; +import com.android.billingclient.api.SkuDetails; +import com.android.billingclient.api.SkuDetailsParams; + +import org.citra.citra_emu.CitraApplication; +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.settings.utils.SettingsFile; +import org.citra.citra_emu.ui.main.MainActivity; + +import java.util.ArrayList; +import java.util.List; + +public class BillingManager implements PurchasesUpdatedListener { + private final String BILLING_SKU_PREMIUM = "citra.citra_emu.product_id.premium"; + + private final Activity mActivity; + private BillingClient mBillingClient; + private SkuDetails mSkuPremium; + private boolean mIsPremiumActive = false; + private boolean mIsServiceConnected = false; + private Runnable mUpdateBillingCallback; + + private static SharedPreferences mPreferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext()); + + public BillingManager(Activity activity) { + mActivity = activity; + mBillingClient = BillingClient.newBuilder(mActivity).enablePendingPurchases().setListener(this).build(); + querySkuDetails(); + } + + static public boolean isPremiumCached() { + return mPreferences.getBoolean(SettingsFile.KEY_PREMIUM, false); + } + + /** + * @return true if Premium subscription is currently active + */ + public boolean isPremiumActive() { + return mIsPremiumActive; + } + + /** + * Invokes the billing flow for Premium + * + * @param callback Optional callback, called once, on completion of billing + */ + public void invokePremiumBilling(Runnable callback) { + if (mSkuPremium == null) { + return; + } + + // Optional callback to refresh the UI for the caller when billing completes + mUpdateBillingCallback = callback; + + // Invoke the billing flow + BillingFlowParams flowParams = BillingFlowParams.newBuilder() + .setSkuDetails(mSkuPremium) + .build(); + mBillingClient.launchBillingFlow(mActivity, flowParams); + } + + private void updatePremiumState(boolean isPremiumActive) { + mIsPremiumActive = isPremiumActive; + + // Cache state for synchronous UI + SharedPreferences.Editor editor = mPreferences.edit(); + editor.putBoolean(SettingsFile.KEY_PREMIUM, isPremiumActive); + editor.apply(); + + // No need to show button in action bar if Premium is active + MainActivity.setPremiumButtonVisible(!isPremiumActive); + } + + @Override + public void onPurchasesUpdated(BillingResult billingResult, List purchaseList) { + if (purchaseList == null || purchaseList.isEmpty()) { + // Premium is not active, or billing is unavailable + updatePremiumState(false); + return; + } + + Purchase premiumPurchase = null; + for (Purchase purchase : purchaseList) { + if (purchase.getSku().equals(BILLING_SKU_PREMIUM)) { + premiumPurchase = purchase; + } + } + + if (premiumPurchase != null && premiumPurchase.getPurchaseState() == Purchase.PurchaseState.PURCHASED) { + // Premium has been purchased + updatePremiumState(true); + + // Acknowledge the purchase if it hasn't already been acknowledged. + if (!premiumPurchase.isAcknowledged()) { + AcknowledgePurchaseParams acknowledgePurchaseParams = + AcknowledgePurchaseParams.newBuilder() + .setPurchaseToken(premiumPurchase.getPurchaseToken()) + .build(); + + AcknowledgePurchaseResponseListener acknowledgePurchaseResponseListener = billingResult1 -> { + Toast.makeText(mActivity, R.string.premium_settings_welcome, Toast.LENGTH_SHORT).show(); + }; + mBillingClient.acknowledgePurchase(acknowledgePurchaseParams, acknowledgePurchaseResponseListener); + } + + if (mUpdateBillingCallback != null) { + try { + mUpdateBillingCallback.run(); + } catch (Exception e) { + e.printStackTrace(); + } + mUpdateBillingCallback = null; + } + } + } + + private void onQuerySkuDetailsFinished(List skuDetailsList) { + if (skuDetailsList == null) { + // This can happen when no user is signed in + return; + } + + if (skuDetailsList.isEmpty()) { + return; + } + + mSkuPremium = skuDetailsList.get(0); + + queryPurchases(); + } + + private void querySkuDetails() { + Runnable queryToExecute = new Runnable() { + @Override + public void run() { + SkuDetailsParams.Builder params = SkuDetailsParams.newBuilder(); + List skuList = new ArrayList<>(); + + skuList.add(BILLING_SKU_PREMIUM); + params.setSkusList(skuList).setType(BillingClient.SkuType.INAPP); + + mBillingClient.querySkuDetailsAsync(params.build(), + (billingResult, skuDetailsList) -> onQuerySkuDetailsFinished(skuDetailsList)); + } + }; + + executeServiceRequest(queryToExecute); + } + + private void onQueryPurchasesFinished(PurchasesResult result) { + // Have we been disposed of in the meantime? If so, or bad result code, then quit + if (mBillingClient == null || result.getResponseCode() != BillingClient.BillingResponseCode.OK) { + updatePremiumState(false); + return; + } + // Update the UI and purchases inventory with new list of purchases + onPurchasesUpdated(result.getBillingResult(), result.getPurchasesList()); + } + + private void queryPurchases() { + Runnable queryToExecute = new Runnable() { + @Override + public void run() { + final PurchasesResult purchasesResult = mBillingClient.queryPurchases(BillingClient.SkuType.INAPP); + onQueryPurchasesFinished(purchasesResult); + } + }; + + executeServiceRequest(queryToExecute); + } + + private void startServiceConnection(final Runnable executeOnFinish) { + mBillingClient.startConnection(new BillingClientStateListener() { + @Override + public void onBillingSetupFinished(BillingResult billingResult) { + if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK) { + mIsServiceConnected = true; + } + + if (executeOnFinish != null) { + executeOnFinish.run(); + } + } + + @Override + public void onBillingServiceDisconnected() { + mIsServiceConnected = false; + } + }); + } + + private void executeServiceRequest(Runnable runnable) { + if (mIsServiceConnected) { + runnable.run(); + } else { + // If billing service was disconnected, we try to reconnect 1 time. + startServiceConnection(runnable); + } + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/ControllerMappingHelper.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/ControllerMappingHelper.java new file mode 100644 index 000000000..f801a05f0 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/ControllerMappingHelper.java @@ -0,0 +1,66 @@ +package org.citra.citra_emu.utils; + +import android.view.InputDevice; +import android.view.KeyEvent; +import android.view.MotionEvent; + +/** + * Some controllers have incorrect mappings. This class has special-case fixes for them. + */ +public class ControllerMappingHelper { + /** + * Some controllers report extra button presses that can be ignored. + */ + public boolean shouldKeyBeIgnored(InputDevice inputDevice, int keyCode) { + if (isDualShock4(inputDevice)) { + // The two analog triggers generate analog motion events as well as a keycode. + // We always prefer to use the analog values, so throw away the button press + return keyCode == KeyEvent.KEYCODE_BUTTON_L2 || keyCode == KeyEvent.KEYCODE_BUTTON_R2; + } + return false; + } + + /** + * Scale an axis to be zero-centered with a proper range. + */ + public float scaleAxis(InputDevice inputDevice, int axis, float value) { + if (isDualShock4(inputDevice)) { + // Android doesn't have correct mappings for this controller's triggers. It reports them + // as RX & RY, centered at -1.0, and with a range of [-1.0, 1.0] + // Scale them to properly zero-centered with a range of [0.0, 1.0]. + if (axis == MotionEvent.AXIS_RX || axis == MotionEvent.AXIS_RY) { + return (value + 1) / 2.0f; + } + } else if (isXboxOneWireless(inputDevice)) { + // Same as the DualShock 4, the mappings are missing. + if (axis == MotionEvent.AXIS_Z || axis == MotionEvent.AXIS_RZ) { + return (value + 1) / 2.0f; + } + if (axis == MotionEvent.AXIS_GENERIC_1) { + // This axis is stuck at ~.5. Ignore it. + return 0.0f; + } + } else if (isMogaPro2Hid(inputDevice)) { + // This controller has a broken axis that reports a constant value. Ignore it. + if (axis == MotionEvent.AXIS_GENERIC_1) { + return 0.0f; + } + } + return value; + } + + private boolean isDualShock4(InputDevice inputDevice) { + // Sony DualShock 4 controller + return inputDevice.getVendorId() == 0x54c && inputDevice.getProductId() == 0x9cc; + } + + private boolean isXboxOneWireless(InputDevice inputDevice) { + // Microsoft Xbox One controller + return inputDevice.getVendorId() == 0x45e && inputDevice.getProductId() == 0x2e0; + } + + private boolean isMogaPro2Hid(InputDevice inputDevice) { + // Moga Pro 2 HID + return inputDevice.getVendorId() == 0x20d6 && inputDevice.getProductId() == 0x6271; + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/DirectoryInitialization.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/DirectoryInitialization.java new file mode 100644 index 000000000..58e552f5e --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/DirectoryInitialization.java @@ -0,0 +1,186 @@ +/** + * Copyright 2014 Dolphin Emulator Project + * Licensed under GPLv2+ + * Refer to the license.txt file included. + */ + +package org.citra.citra_emu.utils; + +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.os.Environment; +import android.preference.PreferenceManager; + +import androidx.localbroadcastmanager.content.LocalBroadcastManager; + +import org.citra.citra_emu.NativeLibrary; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * A service that spawns its own thread in order to copy several binary and shader files + * from the Citra APK to the external file system. + */ +public final class DirectoryInitialization { + public static final String BROADCAST_ACTION = "org.citra.citra_emu.BROADCAST"; + + public static final String EXTRA_STATE = "directoryState"; + private static volatile DirectoryInitializationState directoryState = null; + private static String userPath; + private static AtomicBoolean isCitraDirectoryInitializationRunning = new AtomicBoolean(false); + + public static void start(Context context) { + // Can take a few seconds to run, so don't block UI thread. + //noinspection TrivialFunctionalExpressionUsage + ((Runnable) () -> init(context)).run(); + } + + private static void init(Context context) { + if (!isCitraDirectoryInitializationRunning.compareAndSet(false, true)) + return; + + if (directoryState != DirectoryInitializationState.CITRA_DIRECTORIES_INITIALIZED) { + if (PermissionsHandler.hasWriteAccess(context)) { + if (setCitraUserDirectory()) { + initializeInternalStorage(context); + NativeLibrary.CreateConfigFile(); + directoryState = DirectoryInitializationState.CITRA_DIRECTORIES_INITIALIZED; + } else { + directoryState = DirectoryInitializationState.CANT_FIND_EXTERNAL_STORAGE; + } + } else { + directoryState = DirectoryInitializationState.EXTERNAL_STORAGE_PERMISSION_NEEDED; + } + } + + isCitraDirectoryInitializationRunning.set(false); + sendBroadcastState(directoryState, context); + } + + private static void deleteDirectoryRecursively(File file) { + if (file.isDirectory()) { + for (File child : file.listFiles()) + deleteDirectoryRecursively(child); + } + file.delete(); + } + + public static boolean areCitraDirectoriesReady() { + return directoryState == DirectoryInitializationState.CITRA_DIRECTORIES_INITIALIZED; + } + + public static String getUserDirectory() { + if (directoryState == null) { + throw new IllegalStateException("DirectoryInitialization has to run at least once!"); + } else if (isCitraDirectoryInitializationRunning.get()) { + throw new IllegalStateException( + "DirectoryInitialization has to finish running first!"); + } + return userPath; + } + + private static native void SetSysDirectory(String path); + + private static boolean setCitraUserDirectory() { + if (Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState())) { + File externalPath = Environment.getExternalStorageDirectory(); + if (externalPath != null) { + userPath = externalPath.getAbsolutePath() + "/citra-emu"; + Log.debug("[DirectoryInitialization] User Dir: " + userPath); + // NativeLibrary.SetUserDirectory(userPath); + return true; + } + + } + + return false; + } + + private static void initializeInternalStorage(Context context) { + File sysDirectory = new File(context.getFilesDir(), "Sys"); + + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context); + String revision = NativeLibrary.GetGitRevision(); + if (!preferences.getString("sysDirectoryVersion", "").equals(revision)) { + // There is no extracted Sys directory, or there is a Sys directory from another + // version of Citra that might contain outdated files. Let's (re-)extract Sys. + deleteDirectoryRecursively(sysDirectory); + copyAssetFolder("Sys", sysDirectory, true, context); + + SharedPreferences.Editor editor = preferences.edit(); + editor.putString("sysDirectoryVersion", revision); + editor.apply(); + } + + // Let the native code know where the Sys directory is. + SetSysDirectory(sysDirectory.getPath()); + } + + private static void sendBroadcastState(DirectoryInitializationState state, Context context) { + Intent localIntent = + new Intent(BROADCAST_ACTION) + .putExtra(EXTRA_STATE, state); + LocalBroadcastManager.getInstance(context).sendBroadcast(localIntent); + } + + private static void copyAsset(String asset, File output, Boolean overwrite, Context context) { + Log.verbose("[DirectoryInitialization] Copying File " + asset + " to " + output); + + try { + if (!output.exists() || overwrite) { + InputStream in = context.getAssets().open(asset); + OutputStream out = new FileOutputStream(output); + copyFile(in, out); + in.close(); + out.close(); + } + } catch (IOException e) { + Log.error("[DirectoryInitialization] Failed to copy asset file: " + asset + + e.getMessage()); + } + } + + private static void copyAssetFolder(String assetFolder, File outputFolder, Boolean overwrite, + Context context) { + Log.verbose("[DirectoryInitialization] Copying Folder " + assetFolder + " to " + + outputFolder); + + try { + boolean createdFolder = false; + for (String file : context.getAssets().list(assetFolder)) { + if (!createdFolder) { + outputFolder.mkdir(); + createdFolder = true; + } + copyAssetFolder(assetFolder + File.separator + file, new File(outputFolder, file), + overwrite, context); + copyAsset(assetFolder + File.separator + file, new File(outputFolder, file), overwrite, + context); + } + } catch (IOException e) { + Log.error("[DirectoryInitialization] Failed to copy asset folder: " + assetFolder + + e.getMessage()); + } + } + + private static void copyFile(InputStream in, OutputStream out) throws IOException { + byte[] buffer = new byte[1024]; + int read; + + while ((read = in.read(buffer)) != -1) { + out.write(buffer, 0, read); + } + } + + public enum DirectoryInitializationState { + CITRA_DIRECTORIES_INITIALIZED, + EXTERNAL_STORAGE_PERMISSION_NEEDED, + CANT_FIND_EXTERNAL_STORAGE + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/DirectoryStateReceiver.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/DirectoryStateReceiver.java new file mode 100644 index 000000000..5d1e951ca --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/DirectoryStateReceiver.java @@ -0,0 +1,22 @@ +package org.citra.citra_emu.utils; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; + +import org.citra.citra_emu.utils.DirectoryInitialization.DirectoryInitializationState; + +public class DirectoryStateReceiver extends BroadcastReceiver { + Action1 callback; + + public DirectoryStateReceiver(Action1 callback) { + this.callback = callback; + } + + @Override + public void onReceive(Context context, Intent intent) { + DirectoryInitializationState state = (DirectoryInitializationState) intent + .getSerializableExtra(DirectoryInitialization.EXTRA_STATE); + callback.call(state); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/EmulationMenuSettings.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/EmulationMenuSettings.java new file mode 100644 index 000000000..9664f8464 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/EmulationMenuSettings.java @@ -0,0 +1,78 @@ +package org.citra.citra_emu.utils; + +import android.content.SharedPreferences; +import android.preference.PreferenceManager; + +import org.citra.citra_emu.CitraApplication; + +public class EmulationMenuSettings { + private static SharedPreferences mPreferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext()); + + // These must match what is defined in src/core/settings.h + public static final int LayoutOption_Default = 0; + public static final int LayoutOption_SingleScreen = 1; + public static final int LayoutOption_LargeScreen = 2; + public static final int LayoutOption_SideScreen = 3; + public static final int LayoutOption_MobilePortrait = 4; + public static final int LayoutOption_MobileLandscape = 5; + + public static boolean getJoystickRelCenter() { + return mPreferences.getBoolean("EmulationMenuSettings_JoystickRelCenter", true); + } + + public static void setJoystickRelCenter(boolean value) { + final SharedPreferences.Editor editor = mPreferences.edit(); + editor.putBoolean("EmulationMenuSettings_JoystickRelCenter", value); + editor.apply(); + } + + public static boolean getDpadSlideEnable() { + return mPreferences.getBoolean("EmulationMenuSettings_DpadSlideEnable", true); + } + + public static void setDpadSlideEnable(boolean value) { + final SharedPreferences.Editor editor = mPreferences.edit(); + editor.putBoolean("EmulationMenuSettings_DpadSlideEnable", value); + editor.apply(); + } + + public static int getLandscapeScreenLayout() { + return mPreferences.getInt("EmulationMenuSettings_LandscapeScreenLayout", LayoutOption_MobileLandscape); + } + + public static void setLandscapeScreenLayout(int value) { + final SharedPreferences.Editor editor = mPreferences.edit(); + editor.putInt("EmulationMenuSettings_LandscapeScreenLayout", value); + editor.apply(); + } + + public static boolean getShowFps() { + return mPreferences.getBoolean("EmulationMenuSettings_ShowFps", false); + } + + public static void setShowFps(boolean value) { + final SharedPreferences.Editor editor = mPreferences.edit(); + editor.putBoolean("EmulationMenuSettings_ShowFps", value); + editor.apply(); + } + + public static boolean getSwapScreens() { + return mPreferences.getBoolean("EmulationMenuSettings_SwapScreens", false); + } + + public static void setSwapScreens(boolean value) { + final SharedPreferences.Editor editor = mPreferences.edit(); + editor.putBoolean("EmulationMenuSettings_SwapScreens", value); + editor.apply(); + } + + public static boolean getShowOverlay() { + return mPreferences.getBoolean("EmulationMenuSettings_ShowOverylay", true); + } + + public static void setShowOverlay(boolean value) { + final SharedPreferences.Editor editor = mPreferences.edit(); + editor.putBoolean("EmulationMenuSettings_ShowOverylay", value); + editor.apply(); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/FileBrowserHelper.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/FileBrowserHelper.java new file mode 100644 index 000000000..baf691f5c --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/FileBrowserHelper.java @@ -0,0 +1,73 @@ +package org.citra.citra_emu.utils; + +import android.content.Intent; +import android.net.Uri; +import android.os.Environment; + +import androidx.annotation.Nullable; +import androidx.fragment.app.FragmentActivity; + +import com.nononsenseapps.filepicker.FilePickerActivity; +import com.nononsenseapps.filepicker.Utils; + +import org.citra.citra_emu.activities.CustomFilePickerActivity; + +import java.io.File; +import java.util.List; + +public final class FileBrowserHelper { + public static void openDirectoryPicker(FragmentActivity activity, int requestCode, int title, List extensions) { + Intent i = new Intent(activity, CustomFilePickerActivity.class); + + i.putExtra(FilePickerActivity.EXTRA_ALLOW_MULTIPLE, false); + i.putExtra(FilePickerActivity.EXTRA_ALLOW_CREATE_DIR, false); + i.putExtra(FilePickerActivity.EXTRA_MODE, FilePickerActivity.MODE_DIR); + i.putExtra(FilePickerActivity.EXTRA_START_PATH, + Environment.getExternalStorageDirectory().getPath()); + i.putExtra(CustomFilePickerActivity.EXTRA_TITLE, title); + i.putExtra(CustomFilePickerActivity.EXTRA_EXTENSIONS, String.join(",", extensions)); + + activity.startActivityForResult(i, requestCode); + } + + public static void openFilePicker(FragmentActivity activity, int requestCode, int title, + List extensions, boolean allowMultiple) { + Intent i = new Intent(activity, CustomFilePickerActivity.class); + + i.putExtra(FilePickerActivity.EXTRA_ALLOW_MULTIPLE, allowMultiple); + i.putExtra(FilePickerActivity.EXTRA_ALLOW_CREATE_DIR, false); + i.putExtra(FilePickerActivity.EXTRA_MODE, FilePickerActivity.MODE_FILE); + i.putExtra(FilePickerActivity.EXTRA_START_PATH, + Environment.getExternalStorageDirectory().getPath()); + i.putExtra(CustomFilePickerActivity.EXTRA_TITLE, title); + i.putExtra(CustomFilePickerActivity.EXTRA_EXTENSIONS, String.join(",", extensions)); + + activity.startActivityForResult(i, requestCode); + } + + @Nullable + public static String getSelectedDirectory(Intent result) { + // Use the provided utility method to parse the result + List files = Utils.getSelectedFilesFromResult(result); + if (!files.isEmpty()) { + File file = Utils.getFileForUri(files.get(0)); + return file.getAbsolutePath(); + } + + return null; + } + + @Nullable + public static String[] getSelectedFiles(Intent result) { + // Use the provided utility method to parse the result + List files = Utils.getSelectedFilesFromResult(result); + if (!files.isEmpty()) { + String[] paths = new String[files.size()]; + for (int i = 0; i < files.size(); i++) + paths[i] = Utils.getFileForUri(files.get(i)).getAbsolutePath(); + return paths; + } + + return null; + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/FileUtil.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/FileUtil.java new file mode 100644 index 000000000..f9025171b --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/FileUtil.java @@ -0,0 +1,37 @@ +package org.citra.citra_emu.utils; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; + +public class FileUtil { + public static byte[] getBytesFromFile(File file) throws IOException { + final long length = file.length(); + + // You cannot create an array using a long type. + if (length > Integer.MAX_VALUE) { + // File is too large + throw new IOException("File is too large!"); + } + + byte[] bytes = new byte[(int) length]; + + int offset = 0; + int numRead; + + try (InputStream is = new FileInputStream(file)) { + while (offset < bytes.length + && (numRead = is.read(bytes, offset, bytes.length - offset)) >= 0) { + offset += numRead; + } + } + + // Ensure all the bytes have been read in + if (offset < bytes.length) { + throw new IOException("Could not completely read file " + file.getName()); + } + + return bytes; + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/ForegroundService.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/ForegroundService.java new file mode 100644 index 000000000..bc256877b --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/ForegroundService.java @@ -0,0 +1,63 @@ +/** + * Copyright 2014 Dolphin Emulator Project + * Licensed under GPLv2+ + * Refer to the license.txt file included. + */ + +package org.citra.citra_emu.utils; + +import android.app.PendingIntent; +import android.app.Service; +import android.content.Intent; +import android.os.IBinder; + +import androidx.core.app.NotificationCompat; +import androidx.core.app.NotificationManagerCompat; + +import org.citra.citra_emu.R; +import org.citra.citra_emu.activities.EmulationActivity; + +/** + * A service that shows a permanent notification in the background to avoid the app getting + * cleared from memory by the system. + */ +public class ForegroundService extends Service { + private static final int EMULATION_RUNNING_NOTIFICATION = 0x1000; + + private void showRunningNotification() { + // Intent is used to resume emulation if the notification is clicked + PendingIntent contentIntent = PendingIntent.getActivity(this, 0, + new Intent(this, EmulationActivity.class), PendingIntent.FLAG_UPDATE_CURRENT); + + NotificationCompat.Builder builder = new NotificationCompat.Builder(this, getString(R.string.app_notification_channel_id)) + .setSmallIcon(R.drawable.ic_stat_notification_logo) + .setContentTitle(getString(R.string.app_name)) + .setContentText(getString(R.string.app_notification_running)) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setOngoing(true) + .setVibrate(null) + .setSound(null) + .setContentIntent(contentIntent); + startForeground(EMULATION_RUNNING_NOTIFICATION, builder.build()); + } + + @Override + public IBinder onBind(Intent intent) { + return null; + } + + @Override + public void onCreate() { + showRunningNotification(); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + return START_STICKY; + } + + @Override + public void onDestroy() { + NotificationManagerCompat.from(this).cancel(EMULATION_RUNNING_NOTIFICATION); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/GameIconRequestHandler.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/GameIconRequestHandler.java new file mode 100644 index 000000000..b790c2480 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/GameIconRequestHandler.java @@ -0,0 +1,27 @@ +package org.citra.citra_emu.utils; + +import android.graphics.Bitmap; + +import com.squareup.picasso.Picasso; +import com.squareup.picasso.Request; +import com.squareup.picasso.RequestHandler; + +import org.citra.citra_emu.NativeLibrary; + +import java.nio.IntBuffer; + +public class GameIconRequestHandler extends RequestHandler { + @Override + public boolean canHandleRequest(Request data) { + return "iso".equals(data.uri.getScheme()); + } + + @Override + public Result load(Request request, int networkPolicy) { + String url = request.uri.getHost() + request.uri.getPath(); + int[] vector = NativeLibrary.GetIcon(url); + Bitmap bitmap = Bitmap.createBitmap(48, 48, Bitmap.Config.RGB_565); + bitmap.copyPixelsFromBuffer(IntBuffer.wrap(vector)); + return new Result(bitmap, Picasso.LoadedFrom.DISK); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/Log.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/Log.java new file mode 100644 index 000000000..070d01eb1 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/Log.java @@ -0,0 +1,39 @@ +package org.citra.citra_emu.utils; + +import org.citra.citra_emu.BuildConfig; + +/** + * Contains methods that call through to {@link android.util.Log}, but + * with the same TAG automatically provided. Also no-ops VERBOSE and DEBUG log + * levels in release builds. + */ +public final class Log { + private static final String TAG = "Citra Frontend"; + + private Log() { + } + + public static void verbose(String message) { + if (BuildConfig.DEBUG) { + android.util.Log.v(TAG, message); + } + } + + public static void debug(String message) { + if (BuildConfig.DEBUG) { + android.util.Log.d(TAG, message); + } + } + + public static void info(String message) { + android.util.Log.i(TAG, message); + } + + public static void warning(String message) { + android.util.Log.w(TAG, message); + } + + public static void error(String message) { + android.util.Log.e(TAG, message); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/PermissionsHandler.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/PermissionsHandler.java new file mode 100644 index 000000000..a29e23e8d --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/PermissionsHandler.java @@ -0,0 +1,35 @@ +package org.citra.citra_emu.utils; + +import android.annotation.TargetApi; +import android.content.Context; +import android.content.pm.PackageManager; +import android.os.Build; + +import androidx.core.content.ContextCompat; +import androidx.fragment.app.FragmentActivity; + +import static android.Manifest.permission.WRITE_EXTERNAL_STORAGE; + +public class PermissionsHandler { + public static final int REQUEST_CODE_WRITE_PERMISSION = 500; + + // We use permissions acceptance as an indicator if this is a first boot for the user. + public static boolean isFirstBoot(final FragmentActivity activity) { + return ContextCompat.checkSelfPermission(activity, WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED; + } + + @TargetApi(Build.VERSION_CODES.M) + public static boolean checkWritePermission(final FragmentActivity activity) { + if (isFirstBoot(activity)) { + activity.requestPermissions(new String[]{WRITE_EXTERNAL_STORAGE}, + REQUEST_CODE_WRITE_PERMISSION); + return false; + } + + return true; + } + + public static boolean hasWriteAccess(Context context) { + return ContextCompat.checkSelfPermission(context, WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED; + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/PicassoRoundedCornersTransformation.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/PicassoRoundedCornersTransformation.java new file mode 100644 index 000000000..892b46387 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/PicassoRoundedCornersTransformation.java @@ -0,0 +1,45 @@ +package org.citra.citra_emu.utils; + +import android.graphics.Bitmap; +import android.graphics.BitmapShader; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Rect; +import android.graphics.RectF; + +import com.squareup.picasso.Transformation; + +public class PicassoRoundedCornersTransformation implements Transformation { + @Override + public Bitmap transform(Bitmap icon) { + final int width = icon.getWidth(); + final int height = icon.getHeight(); + final Rect rect = new Rect(0, 0, width, height); + final int size = Math.min(width, height); + final int x = (width - size) / 2; + final int y = (height - size) / 2; + + Bitmap squaredBitmap = Bitmap.createBitmap(icon, x, y, size, size); + if (squaredBitmap != icon) { + icon.recycle(); + } + + Bitmap output = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(output); + BitmapShader shader = new BitmapShader(squaredBitmap, BitmapShader.TileMode.CLAMP, BitmapShader.TileMode.CLAMP); + Paint paint = new Paint(); + paint.setAntiAlias(true); + paint.setShader(shader); + + canvas.drawRoundRect(new RectF(rect), 10, 10, paint); + + squaredBitmap.recycle(); + + return output; + } + + @Override + public String key() { + return "circle"; + } +} \ No newline at end of file diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/PicassoUtils.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/PicassoUtils.java new file mode 100644 index 000000000..c99726685 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/PicassoUtils.java @@ -0,0 +1,57 @@ +package org.citra.citra_emu.utils; + +import android.graphics.Bitmap; +import android.net.Uri; +import android.widget.ImageView; + +import com.squareup.picasso.Picasso; + +import org.citra.citra_emu.CitraApplication; +import org.citra.citra_emu.R; + +import java.io.IOException; + +import androidx.annotation.Nullable; + +public class PicassoUtils { + private static boolean mPicassoInitialized = false; + + public static void init() { + if (mPicassoInitialized) { + return; + } + Picasso picassoInstance = new Picasso.Builder(CitraApplication.getAppContext()) + .addRequestHandler(new GameIconRequestHandler()) + .build(); + + Picasso.setSingletonInstance(picassoInstance); + mPicassoInitialized = true; + } + + public static void loadGameIcon(ImageView imageView, String gamePath) { + Picasso + .get() + .load(Uri.parse("iso:/" + gamePath)) + .fit() + .centerInside() + .config(Bitmap.Config.RGB_565) + .error(R.drawable.no_icon) + .transform(new PicassoRoundedCornersTransformation()) + .into(imageView); + } + + // Blocking call. Load image from file and crop/resize it to fit in width x height. + @Nullable + public static Bitmap LoadBitmapFromFile(String uri, int width, int height) { + try { + return Picasso.get() + .load(Uri.parse(uri)) + .config(Bitmap.Config.ARGB_8888) + .centerCrop() + .resize(width, height) + .get(); + } catch (IOException e) { + return null; + } + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/StartupHandler.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/StartupHandler.java new file mode 100644 index 000000000..9112bf90c --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/StartupHandler.java @@ -0,0 +1,45 @@ +package org.citra.citra_emu.utils; + +import android.content.Intent; +import android.os.Bundle; +import android.text.TextUtils; + +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.FragmentActivity; + +import org.citra.citra_emu.R; +import org.citra.citra_emu.activities.EmulationActivity; + +public final class StartupHandler { + private static void handlePermissionsCheck(FragmentActivity parent) { + // Ask the user to grant write permission if it's not already granted + PermissionsHandler.checkWritePermission(parent); + + String start_file = ""; + Bundle extras = parent.getIntent().getExtras(); + if (extras != null) { + start_file = extras.getString("AutoStartFile"); + } + + if (!TextUtils.isEmpty(start_file)) { + // Start the emulation activity, send the ISO passed in and finish the main activity + Intent emulation_intent = new Intent(parent, EmulationActivity.class); + emulation_intent.putExtra("SelectedGame", start_file); + parent.startActivity(emulation_intent); + parent.finish(); + } + } + + public static void HandleInit(FragmentActivity parent) { + if (PermissionsHandler.isFirstBoot(parent)) { + // Prompt user with standard first boot disclaimer + new AlertDialog.Builder(parent) + .setTitle(R.string.app_name) + .setIcon(R.mipmap.ic_launcher) + .setMessage(parent.getResources().getString(R.string.app_disclaimer)) + .setPositiveButton(android.R.string.ok, null) + .setOnDismissListener(dialogInterface -> handlePermissionsCheck(parent)) + .show(); + } + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/ThemeUtil.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/ThemeUtil.java new file mode 100644 index 000000000..74ef3867f --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/ThemeUtil.java @@ -0,0 +1,34 @@ +package org.citra.citra_emu.utils; + +import android.content.SharedPreferences; +import android.os.Build; +import android.preference.PreferenceManager; + +import androidx.appcompat.app.AppCompatDelegate; + +import org.citra.citra_emu.CitraApplication; +import org.citra.citra_emu.features.settings.utils.SettingsFile; + +public class ThemeUtil { + private static SharedPreferences mPreferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext()); + + private static void applyTheme(int designValue) { + switch (designValue) { + case 0: + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO); + break; + case 1: + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES); + break; + case 2: + AppCompatDelegate.setDefaultNightMode(android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ? + AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM : + AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY); + break; + } + } + + public static void applyTheme() { + applyTheme(mPreferences.getInt(SettingsFile.KEY_DESIGN, 0)); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/viewholders/GameViewHolder.java b/src/android/app/src/main/java/org/citra/citra_emu/viewholders/GameViewHolder.java new file mode 100644 index 000000000..50dbcbe18 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/viewholders/GameViewHolder.java @@ -0,0 +1,46 @@ +package org.citra.citra_emu.viewholders; + +import android.view.View; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.recyclerview.widget.RecyclerView; + +import org.citra.citra_emu.R; + +/** + * A simple class that stores references to views so that the GameAdapter doesn't need to + * keep calling findViewById(), which is expensive. + */ +public class GameViewHolder extends RecyclerView.ViewHolder { + private View itemView; + public ImageView imageIcon; + public TextView textGameTitle; + public TextView textCompany; + public TextView textFileName; + + public String gameId; + + // TODO Not need any of this stuff. Currently only the properties dialog needs it. + public String path; + public String title; + public String description; + public String regions; + public String company; + + public GameViewHolder(View itemView) { + super(itemView); + + this.itemView = itemView; + itemView.setTag(this); + + imageIcon = itemView.findViewById(R.id.image_game_screen); + textGameTitle = itemView.findViewById(R.id.text_game_title); + textCompany = itemView.findViewById(R.id.text_company); + textFileName = itemView.findViewById(R.id.text_filename); + } + + public View getItemView() { + return itemView; + } +} diff --git a/src/android/app/src/main/java/org/citra_emu/citra/CitraApplication.java b/src/android/app/src/main/java/org/citra_emu/citra/CitraApplication.java deleted file mode 100644 index 10cb52783..000000000 --- a/src/android/app/src/main/java/org/citra_emu/citra/CitraApplication.java +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright 2018 Citra Emulator Project -// Licensed under GPLv2 or any later version -// Refer to the license.txt file included. - -package org.citra_emu.citra; - -import android.app.Application; - -public class CitraApplication extends Application { - static { - System.loadLibrary("citra-android"); - } -} diff --git a/src/android/app/src/main/java/org/citra_emu/citra/LOG.java b/src/android/app/src/main/java/org/citra_emu/citra/LOG.java deleted file mode 100644 index c52f30b68..000000000 --- a/src/android/app/src/main/java/org/citra_emu/citra/LOG.java +++ /dev/null @@ -1,41 +0,0 @@ -package org.citra_emu.citra; - -public class LOG { - - private interface LOG_LEVEL { - int TRACE = 0, DEBUG = 1, INFO = 2, WARNING = 3, ERROR = 4, CRITICAL = 5; - } - - public static void TRACE(String msg, Object... args) { - LOG(LOG_LEVEL.TRACE, msg, args); - } - - public static void DEBUG(String msg, Object... args) { - LOG(LOG_LEVEL.DEBUG, msg, args); - } - - public static void INFO(String msg, Object... args) { - LOG(LOG_LEVEL.INFO, msg, args); - } - - public static void WARNING(String msg, Object... args) { - LOG(LOG_LEVEL.WARNING, msg, args); - } - - public static void ERROR(String msg, Object... args) { - LOG(LOG_LEVEL.ERROR, msg, args); - } - - public static void CRITICAL(String msg, Object... args) { - LOG(LOG_LEVEL.CRITICAL, msg, args); - } - - private static void LOG(int level, String msg, Object... args) { - StackTraceElement trace = Thread.currentThread().getStackTrace()[4]; - logEntry(level, trace.getFileName(), trace.getLineNumber(), trace.getMethodName(), - String.format(msg, args)); - } - - private static native void logEntry(int level, String file_name, int line_number, - String function, String message); -} diff --git a/src/android/app/src/main/java/org/citra_emu/citra/ui/main/MainActivity.java b/src/android/app/src/main/java/org/citra_emu/citra/ui/main/MainActivity.java deleted file mode 100644 index 5b4f3d3bc..000000000 --- a/src/android/app/src/main/java/org/citra_emu/citra/ui/main/MainActivity.java +++ /dev/null @@ -1,58 +0,0 @@ -// Copyright 2018 Citra Emulator Project -// Licensed under GPLv2 or any later version -// Refer to the license.txt file included. - -package org.citra_emu.citra.ui.main; - -import android.Manifest; -import android.content.pm.PackageManager; -import android.os.Bundle; -import android.support.annotation.NonNull; -import android.support.v7.app.AlertDialog; -import android.support.v7.app.AppCompatActivity; - -import org.citra_emu.citra.R; -import org.citra_emu.citra.utils.FileUtil; -import org.citra_emu.citra.utils.PermissionUtil; - -public final class MainActivity extends AppCompatActivity { - - // Java enums suck - private interface PermissionCodes { int INITIALIZE = 0; } - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.activity_main); - - PermissionUtil.verifyPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE, - PermissionCodes.INITIALIZE); - } - - @Override - public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, - @NonNull int[] grantResults) { - switch (requestCode) { - case PermissionCodes.INITIALIZE: - if (grantResults[0] == PackageManager.PERMISSION_GRANTED) { - initUserPath(FileUtil.getUserPath().toString()); - initLogging(); - } else { - AlertDialog.Builder dialog = - new AlertDialog.Builder(this) - .setTitle("Permission Error") - .setMessage("Citra requires storage permissions to function.") - .setCancelable(false) - .setPositiveButton("OK", (dialogInterface, which) -> { - PermissionUtil.verifyPermission( - MainActivity.this, Manifest.permission.WRITE_EXTERNAL_STORAGE, - PermissionCodes.INITIALIZE); - }); - dialog.show(); - } - } - } - - private static native void initUserPath(String path); - private static native void initLogging(); -} diff --git a/src/android/app/src/main/java/org/citra_emu/citra/utils/FileUtil.java b/src/android/app/src/main/java/org/citra_emu/citra/utils/FileUtil.java deleted file mode 100644 index 5346c5352..000000000 --- a/src/android/app/src/main/java/org/citra_emu/citra/utils/FileUtil.java +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright 2019 Citra Emulator Project -// Licensed under GPLv2 or any later version -// Refer to the license.txt file included. - -package org.citra_emu.citra.utils; - -import android.os.Environment; - -import java.io.File; - -public class FileUtil { - public static File getUserPath() { - File storage = Environment.getExternalStorageDirectory(); - File userPath = new File(storage, "citra"); - if (!userPath.isDirectory()) - userPath.mkdir(); - return userPath; - } -} diff --git a/src/android/app/src/main/java/org/citra_emu/citra/utils/PermissionUtil.java b/src/android/app/src/main/java/org/citra_emu/citra/utils/PermissionUtil.java deleted file mode 100644 index 33c8129e5..000000000 --- a/src/android/app/src/main/java/org/citra_emu/citra/utils/PermissionUtil.java +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright 2019 Citra Emulator Project -// Licensed under GPLv2 or any later version -// Refer to the license.txt file included. - -package org.citra_emu.citra.utils; - -import android.app.Activity; -import android.content.pm.PackageManager; -import android.support.v4.app.ActivityCompat; -import android.support.v4.content.ContextCompat; - -public class PermissionUtil { - - /** - * Checks a permission, if needed shows a dialog to request it - * - * @param activity the activity requiring the permission - * @param permission the permission needed - * @param requestCode supplied to the callback to determine the next action - */ - public static void verifyPermission(Activity activity, String permission, int requestCode) { - if (ContextCompat.checkSelfPermission(activity, permission) == - PackageManager.PERMISSION_GRANTED) { - // call the callback called by requestPermissions - activity.onRequestPermissionsResult(requestCode, new String[] {permission}, - new int[] {PackageManager.PERMISSION_GRANTED}); - return; - } - - ActivityCompat.requestPermissions(activity, new String[] {permission}, requestCode); - } -} diff --git a/src/android/app/src/main/jni/CMakeLists.txt b/src/android/app/src/main/jni/CMakeLists.txt new file mode 100644 index 000000000..77b95130d --- /dev/null +++ b/src/android/app/src/main/jni/CMakeLists.txt @@ -0,0 +1,34 @@ +add_library(citra-android SHARED + applets/mii_selector.cpp + applets/mii_selector.h + applets/swkbd.cpp + applets/swkbd.h + input_manager.cpp + input_manager.h + camera/ndk_camera.cpp + camera/ndk_camera.h + camera/still_image_camera.cpp + camera/still_image_camera.h + config.cpp + config.h + default_ini.h + emu_window/emu_window.cpp + emu_window/emu_window.h + game_info.cpp + game_info.h + game_settings.cpp + game_settings.h + id_cache.cpp + id_cache.h + mic.cpp + mic.h + native.cpp + native.h + ndk_motion.cpp + ndk_motion.h +) + +target_link_libraries(citra-android PRIVATE audio_core common core input_common network) +target_link_libraries(citra-android PRIVATE android camera2ndk EGL glad inih jnigraphics log mediandk yuv) + +set(CPACK_PACKAGE_EXECUTABLES ${CPACK_PACKAGE_EXECUTABLES} citra-android) diff --git a/src/android/app/src/main/jni/applets/mii_selector.cpp b/src/android/app/src/main/jni/applets/mii_selector.cpp new file mode 100644 index 000000000..0e8e79238 --- /dev/null +++ b/src/android/app/src/main/jni/applets/mii_selector.cpp @@ -0,0 +1,87 @@ +// Copyright 2020 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#include "common/string_util.h" +#include "jni/applets/mii_selector.h" +#include "jni/id_cache.h" + +static jclass s_mii_selector_class; +static jclass s_mii_selector_config_class; +static jclass s_mii_selector_data_class; +static jmethodID s_mii_selector_execute; + +namespace MiiSelector { + +AndroidMiiSelector::~AndroidMiiSelector() = default; + +void AndroidMiiSelector::Setup(const Frontend::MiiSelectorConfig& config) { + JNIEnv* env = IDCache::GetEnvForThread(); + auto miis = Frontend::LoadMiis(); + + // Create the Java MiiSelectorConfig object + jobject java_config = env->AllocObject(s_mii_selector_config_class); + env->SetBooleanField(java_config, + env->GetFieldID(s_mii_selector_config_class, "enable_cancel_button", "Z"), + static_cast(config.enable_cancel_button)); + env->SetObjectField(java_config, + env->GetFieldID(s_mii_selector_config_class, "title", "Ljava/lang/String;"), + env->NewStringUTF(config.title.c_str())); + env->SetLongField( + java_config, + env->GetFieldID(s_mii_selector_config_class, "initially_selected_mii_index", "J"), + static_cast(config.initially_selected_mii_index)); + + // List mii names + // The 'Standard Mii' is not included here as we need Java side to translate it + const jclass string_class = reinterpret_cast(env->FindClass("java/lang/String")); + const jobjectArray array = + env->NewObjectArray(static_cast(miis.size()), string_class, nullptr); + for (std::size_t i = 0; i < miis.size(); ++i) { + const auto name = Common::UTF16BufferToUTF8(miis[i].mii_name); + env->SetObjectArrayElement(array, static_cast(i), env->NewStringUTF(name.c_str())); + } + env->SetObjectField( + java_config, + env->GetFieldID(s_mii_selector_config_class, "mii_names", "[Ljava/lang/String;"), array); + + // Invoke backend Execute method + jobject data = + env->CallStaticObjectMethod(s_mii_selector_class, s_mii_selector_execute, java_config); + + const u32 return_code = static_cast( + env->GetLongField(data, env->GetFieldID(s_mii_selector_data_class, "return_code", "J"))); + if (return_code == 1) { + Finalize(return_code, HLE::Applets::MiiData{}); + return; + } + + const int index = static_cast( + env->GetIntField(data, env->GetFieldID(s_mii_selector_data_class, "index", "I"))); + ASSERT_MSG(index >= 0 && index <= miis.size(), "Index returned is out of bound"); + Finalize(return_code, index == 0 + ? HLE::Applets::MiiSelector::GetStandardMiiResult().selected_mii_data + : miis.at(static_cast(index - 1))); +} + +void InitJNI(JNIEnv* env) { + s_mii_selector_class = reinterpret_cast( + env->NewGlobalRef(env->FindClass("org/citra/citra_emu/applets/MiiSelector"))); + s_mii_selector_config_class = reinterpret_cast(env->NewGlobalRef( + env->FindClass("org/citra/citra_emu/applets/MiiSelector$MiiSelectorConfig"))); + s_mii_selector_data_class = reinterpret_cast(env->NewGlobalRef( + env->FindClass("org/citra/citra_emu/applets/MiiSelector$MiiSelectorData"))); + + s_mii_selector_execute = + env->GetStaticMethodID(s_mii_selector_class, "Execute", + "(Lorg/citra/citra_emu/applets/MiiSelector$MiiSelectorConfig;)Lorg/" + "citra/citra_emu/applets/MiiSelector$MiiSelectorData;"); +} + +void CleanupJNI(JNIEnv* env) { + env->DeleteGlobalRef(s_mii_selector_class); + env->DeleteGlobalRef(s_mii_selector_config_class); + env->DeleteGlobalRef(s_mii_selector_data_class); +} + +} // namespace MiiSelector diff --git a/src/android/app/src/main/jni/applets/mii_selector.h b/src/android/app/src/main/jni/applets/mii_selector.h new file mode 100644 index 000000000..f33d1cb8d --- /dev/null +++ b/src/android/app/src/main/jni/applets/mii_selector.h @@ -0,0 +1,25 @@ +// Copyright 2020 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#pragma once + +#include +#include "core/frontend/applets/mii_selector.h" + +namespace MiiSelector { + +class AndroidMiiSelector final : public Frontend::MiiSelector { +public: + ~AndroidMiiSelector(); + + void Setup(const Frontend::MiiSelectorConfig& config) override; +}; + +// Should be called in JNI_Load +void InitJNI(JNIEnv* env); + +// Should be called in JNI_Unload +void CleanupJNI(JNIEnv* env); + +} // namespace MiiSelector diff --git a/src/android/app/src/main/jni/applets/swkbd.cpp b/src/android/app/src/main/jni/applets/swkbd.cpp new file mode 100644 index 000000000..062d307a6 --- /dev/null +++ b/src/android/app/src/main/jni/applets/swkbd.cpp @@ -0,0 +1,151 @@ +// Copyright 2020 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#include +#include +#include "core/core.h" +#include "jni/applets/swkbd.h" +#include "jni/id_cache.h" + +static std::string GetJString(JNIEnv* env, jstring jstr) { + if (!jstr) { + return {}; + } + + const char* s = env->GetStringUTFChars(jstr, nullptr); + std::string result = s; + env->ReleaseStringUTFChars(jstr, s); + return result; +} + +static jclass s_software_keyboard_class; +static jclass s_keyboard_config_class; +static jclass s_keyboard_data_class; +static jclass s_validation_error_class; +static jmethodID s_swkbd_execute; +static jmethodID s_swkbd_show_error; + +namespace SoftwareKeyboard { + +static jobject ToJavaKeyboardConfig(const Frontend::KeyboardConfig& config) { + JNIEnv* env = IDCache::GetEnvForThread(); + jobject object = env->AllocObject(s_keyboard_config_class); + env->SetIntField(object, env->GetFieldID(s_keyboard_config_class, "button_config", "I"), + static_cast(config.button_config)); + env->SetIntField(object, env->GetFieldID(s_keyboard_config_class, "max_text_length", "I"), + static_cast(config.max_text_length)); + env->SetBooleanField(object, env->GetFieldID(s_keyboard_config_class, "multiline_mode", "Z"), + static_cast(config.multiline_mode)); + env->SetObjectField(object, + env->GetFieldID(s_keyboard_config_class, "hint_text", "Ljava/lang/String;"), + env->NewStringUTF(config.hint_text.c_str())); + + const jclass string_class = reinterpret_cast(env->FindClass("java/lang/String")); + const jobjectArray array = + env->NewObjectArray(static_cast(config.button_text.size()), string_class, + env->NewStringUTF(config.button_text[0].c_str())); + for (std::size_t i = 1; i < config.button_text.size(); ++i) { + env->SetObjectArrayElement(array, static_cast(i), + env->NewStringUTF(config.button_text[i].c_str())); + } + env->SetObjectField( + object, env->GetFieldID(s_keyboard_config_class, "button_text", "[Ljava/lang/String;"), + array); + + return object; +} + +static Frontend::KeyboardData ToFrontendKeyboardData(jobject object) { + JNIEnv* env = IDCache::GetEnvForThread(); + const jstring string = reinterpret_cast(env->GetObjectField( + object, env->GetFieldID(s_keyboard_data_class, "text", "Ljava/lang/String;"))); + return Frontend::KeyboardData{ + GetJString(env, string), + static_cast( + env->GetIntField(object, env->GetFieldID(s_keyboard_data_class, "button", "I")))}; +} + +AndroidKeyboard::~AndroidKeyboard() = default; + +void AndroidKeyboard::Execute(const Frontend::KeyboardConfig& config) { + SoftwareKeyboard::Execute(config); + + const auto data = ToFrontendKeyboardData(IDCache::GetEnvForThread()->CallStaticObjectMethod( + s_software_keyboard_class, s_swkbd_execute, ToJavaKeyboardConfig(config))); + Finalize(data.text, data.button); +} + +void AndroidKeyboard::ShowError(const std::string& error) { + JNIEnv* env = IDCache::GetEnvForThread(); + env->CallStaticVoidMethod(s_software_keyboard_class, s_swkbd_show_error, + env->NewStringUTF(error.c_str())); +} + +void InitJNI(JNIEnv* env) { + s_software_keyboard_class = reinterpret_cast( + env->NewGlobalRef(env->FindClass("org/citra/citra_emu/applets/SoftwareKeyboard"))); + s_keyboard_config_class = reinterpret_cast(env->NewGlobalRef( + env->FindClass("org/citra/citra_emu/applets/SoftwareKeyboard$KeyboardConfig"))); + s_keyboard_data_class = reinterpret_cast(env->NewGlobalRef( + env->FindClass("org/citra/citra_emu/applets/SoftwareKeyboard$KeyboardData"))); + s_validation_error_class = reinterpret_cast(env->NewGlobalRef( + env->FindClass("org/citra/citra_emu/applets/SoftwareKeyboard$ValidationError"))); + + s_swkbd_execute = env->GetStaticMethodID( + s_software_keyboard_class, "Execute", + "(Lorg/citra/citra_emu/applets/SoftwareKeyboard$KeyboardConfig;)Lorg/citra/citra_emu/" + "applets/SoftwareKeyboard$KeyboardData;"); + s_swkbd_show_error = + env->GetStaticMethodID(s_software_keyboard_class, "ShowError", "(Ljava/lang/String;)V"); +} + +void CleanupJNI(JNIEnv* env) { + env->DeleteGlobalRef(s_software_keyboard_class); + env->DeleteGlobalRef(s_keyboard_config_class); + env->DeleteGlobalRef(s_keyboard_data_class); + env->DeleteGlobalRef(s_validation_error_class); +} + +} // namespace SoftwareKeyboard + +jobject ToJavaValidationError(Frontend::ValidationError error) { + static const std::map ValidationErrorNameMap{{ + {Frontend::ValidationError::None, "None"}, + {Frontend::ValidationError::ButtonOutOfRange, "ButtonOutOfRange"}, + {Frontend::ValidationError::MaxDigitsExceeded, "MaxDigitsExceeded"}, + {Frontend::ValidationError::AtSignNotAllowed, "AtSignNotAllowed"}, + {Frontend::ValidationError::PercentNotAllowed, "PercentNotAllowed"}, + {Frontend::ValidationError::BackslashNotAllowed, "BackslashNotAllowed"}, + {Frontend::ValidationError::ProfanityNotAllowed, "ProfanityNotAllowed"}, + {Frontend::ValidationError::CallbackFailed, "CallbackFailed"}, + {Frontend::ValidationError::FixedLengthRequired, "FixedLengthRequired"}, + {Frontend::ValidationError::MaxLengthExceeded, "MaxLengthExceeded"}, + {Frontend::ValidationError::BlankInputNotAllowed, "BlankInputNotAllowed"}, + {Frontend::ValidationError::EmptyInputNotAllowed, "EmptyInputNotAllowed"}, + }}; + ASSERT(ValidationErrorNameMap.count(error)); + + JNIEnv* env = IDCache::GetEnvForThread(); + return env->GetStaticObjectField( + s_validation_error_class, + env->GetStaticFieldID(s_validation_error_class, ValidationErrorNameMap.at(error), + "Lorg/citra/citra_emu/applets/SoftwareKeyboard$ValidationError;")); +} + +jobject Java_org_citra_citra_1emu_applets_SoftwareKeyboard_ValidateFilters(JNIEnv* env, + jclass clazz, + jstring text) { + + const auto ret = + Core::System::GetInstance().GetSoftwareKeyboard()->ValidateFilters(GetJString(env, text)); + return ToJavaValidationError(ret); +} + +jobject Java_org_citra_citra_1emu_applets_SoftwareKeyboard_ValidateInput(JNIEnv* env, jclass clazz, + jstring text) { + + const auto ret = + Core::System::GetInstance().GetSoftwareKeyboard()->ValidateInput(GetJString(env, text)); + return ToJavaValidationError(ret); +} diff --git a/src/android/app/src/main/jni/applets/swkbd.h b/src/android/app/src/main/jni/applets/swkbd.h new file mode 100644 index 000000000..664626695 --- /dev/null +++ b/src/android/app/src/main/jni/applets/swkbd.h @@ -0,0 +1,35 @@ +// Copyright 2020 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#pragma once + +#include +#include "core/frontend/applets/swkbd.h" + +namespace SoftwareKeyboard { + +class AndroidKeyboard final : public Frontend::SoftwareKeyboard { +public: + ~AndroidKeyboard(); + + void Execute(const Frontend::KeyboardConfig& config) override; + void ShowError(const std::string& error) override; +}; + +// Should be called in JNI_Load +void InitJNI(JNIEnv* env); + +// Should be called in JNI_Unload +void CleanupJNI(JNIEnv* env); + +} // namespace SoftwareKeyboard + +// Native function calls +extern "C" { +JNIEXPORT jobject JNICALL Java_org_citra_citra_1emu_applets_SoftwareKeyboard_ValidateFilters( + JNIEnv* env, jclass clazz, jstring text); + +JNIEXPORT jobject JNICALL Java_org_citra_citra_1emu_applets_SoftwareKeyboard_ValidateInput( + JNIEnv* env, jclass clazz, jstring text); +} diff --git a/src/android/app/src/main/jni/camera/ndk_camera.cpp b/src/android/app/src/main/jni/camera/ndk_camera.cpp new file mode 100644 index 000000000..bf8ad18c8 --- /dev/null +++ b/src/android/app/src/main/jni/camera/ndk_camera.cpp @@ -0,0 +1,528 @@ +// Copyright 2020 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#include +#include +#include +#include +#include +#include +#include +#include +#include "common/scope_exit.h" +#include "common/thread.h" +#include "core/frontend/camera/blank_camera.h" +#include "jni/camera/ndk_camera.h" +#include "jni/camera/still_image_camera.h" +#include "jni/id_cache.h" + +namespace Camera::NDK { + +/** + * Implementation detail of NDK camera interface, holding a ton of different structs. + * As long as the object lives, the camera is opened and capturing image. To turn off the camera, + * one needs to destruct the object. + * The pixel format is 'Android 420' which can contain a variety of YUV420 formats. The exact + * format used can be determined by examing the 'pixel stride'. + */ +struct CaptureSession final { + explicit CaptureSession(ACameraManager* manager, const std::string& id) { + Load(manager, id); + } + + void Load(ACameraManager* manager, const std::string& id); + + std::pair selected_resolution{}; + + ACameraDevice_StateCallbacks device_callbacks{}; + AImageReader_ImageListener listener{}; + ACameraCaptureSession_stateCallbacks session_callbacks{}; + std::array requests{}; + +#define MEMBER(type, name, func) \ + struct type##Deleter { \ + void operator()(type* ptr) { \ + type##_##func(ptr); \ + } \ + }; \ + std::unique_ptr name + + MEMBER(ACameraDevice, device, close); + MEMBER(AImageReader, image_reader, delete); + MEMBER(ANativeWindow, native_window, release); + + MEMBER(ACaptureSessionOutputContainer, outputs, free); + MEMBER(ACaptureSessionOutput, output, free); + MEMBER(ACameraOutputTarget, target, free); + MEMBER(ACaptureRequest, request, free); + + // Put session last to close the session before we destruct everything else + MEMBER(ACameraCaptureSession, session, close); +#undef MEMBER + + bool ready = false; + + std::mutex data_mutex; + + // Clang does not yet have shared_ptr to arrays support. Managed data are actually arrays. + std::array, 3> data{}; // I420 format, planes are Y, U, V. + std::array row_stride{}; // Row stride for the planes. + int pixel_stride{}; // Pixel stride for the UV planes. + Common::Event active_event; // Signals that the session has become ready + + int sensor_orientation{}; // Sensor Orientation + bool facing_front{}; // Whether this camera faces front. Used for handling device rotation. + + std::mutex status_mutex; + bool disconnected{}; // Whether this device has been closed and should be reopened + bool reload_requested{}; +}; + +void OnDisconnected(void* context, ACameraDevice* device) { + LOG_WARNING(Service_CAM, "Camera device disconnected"); + + CaptureSession* that = reinterpret_cast(context); + { + std::lock_guard lock{that->status_mutex}; + that->disconnected = true; + } +} + +static void OnError(void* context, ACameraDevice* device, int error) { + LOG_ERROR(Service_CAM, "Camera device error {}", error); +} + +#define MEDIA_CALL(func) \ + { \ + auto ret = func; \ + if (ret != AMEDIA_OK) { \ + LOG_ERROR(Service_CAM, "Call " #func " returned error {}", ret); \ + return; \ + } \ + } + +#define CAMERA_CALL(func) \ + { \ + auto ret = func; \ + if (ret != ACAMERA_OK) { \ + LOG_ERROR(Service_CAM, "Call " #func " returned error {}", ret); \ + return; \ + } \ + } + +void ImageCallback(void* context, AImageReader* reader) { + AImage* image{}; + MEDIA_CALL(AImageReader_acquireLatestImage(reader, &image)); + SCOPE_EXIT({ AImage_delete(image); }); + + std::array, 3> data; + std::array row_stride; + for (const int plane : {0, 1, 2}) { + u8* ptr; + int size; + MEDIA_CALL(AImage_getPlaneData(image, plane, &ptr, &size)); + data[plane].reset(new u8[size], std::default_delete()); + std::memcpy(data[plane].get(), ptr, static_cast(size)); + + MEDIA_CALL(AImage_getPlaneRowStride(image, plane, &row_stride[plane])); + } + + CaptureSession* that = reinterpret_cast(context); + { + std::lock_guard lock{that->data_mutex}; + that->data = data; + that->row_stride = row_stride; + MEDIA_CALL(AImage_getPlanePixelStride(image, 1, &that->pixel_stride)); + } + { + std::lock_guard lock{that->status_mutex}; + if (!that->ready) { + that->active_event.Set(); // Mark the session as active + } + } +} + +#define CREATE(type, name, statement) \ + { \ + type* raw; \ + statement; \ + name.reset(raw); \ + } + +// We have to define these no-op callbacks +static void OnClosed(void* context, ACameraCaptureSession* session) {} +static void OnReady(void* context, ACameraCaptureSession* session) {} +static void OnActive(void* context, ACameraCaptureSession* session) {} + +void CaptureSession::Load(ACameraManager* manager, const std::string& id) { + { + std::lock_guard lock{status_mutex}; + ready = disconnected = reload_requested = false; + } + + device_callbacks = { + /*context*/ this, + /*onDisconnected*/ &OnDisconnected, + /*onError*/ &OnError, + }; + + CREATE(ACameraDevice, device, + CAMERA_CALL(ACameraManager_openCamera(manager, id.c_str(), &device_callbacks, &raw))); + + ACameraMetadata* metadata; + CAMERA_CALL(ACameraManager_getCameraCharacteristics(manager, id.c_str(), &metadata)); + + ACameraMetadata_const_entry entry; + CAMERA_CALL(ACameraMetadata_getConstEntry( + metadata, ACAMERA_SCALER_AVAILABLE_STREAM_CONFIGURATIONS, &entry)); + + // We select the minimum resolution larger than 640x640 if any, or the maximum resolution. + selected_resolution = {}; + for (std::size_t i = 0; i < entry.count; i += 4) { + // (format, width, height, input?) + if (entry.data.i32[i + 3] & ACAMERA_SCALER_AVAILABLE_STREAM_CONFIGURATIONS_INPUT) { + // This is an input stream + continue; + } + + int format = entry.data.i32[i + 0]; + if (format == AIMAGE_FORMAT_YUV_420_888) { + int width = entry.data.i32[i + 1]; + int height = entry.data.i32[i + 2]; + if (selected_resolution.first <= 640 || selected_resolution.second <= 640) { + // Selected resolution is not large enough + selected_resolution = std::max(selected_resolution, std::make_pair(width, height)); + } else if (width >= 640 && height >= 640) { + // Selected resolution and this one are both large enough + selected_resolution = std::min(selected_resolution, std::make_pair(width, height)); + } + } + } + + CAMERA_CALL(ACameraMetadata_getConstEntry(metadata, ACAMERA_SENSOR_ORIENTATION, &entry)); + sensor_orientation = entry.data.i32[0]; + + CAMERA_CALL(ACameraMetadata_getConstEntry(metadata, ACAMERA_LENS_FACING, &entry)); + if (entry.data.i32[0] == ACAMERA_LENS_FACING_FRONT) { + facing_front = true; + } + ACameraMetadata_free(metadata); + + if (selected_resolution == std::pair{}) { + LOG_ERROR(Service_CAM, "Device does not support any YUV output format"); + return; + } + + CREATE(AImageReader, image_reader, + MEDIA_CALL(AImageReader_new(selected_resolution.first, selected_resolution.second, + AIMAGE_FORMAT_YUV_420_888, 4, &raw))); + + listener = { + /*context*/ this, + /*onImageAvailable*/ &ImageCallback, + }; + MEDIA_CALL(AImageReader_setImageListener(image_reader.get(), &listener)); + + CREATE(ANativeWindow, native_window, + MEDIA_CALL(AImageReader_getWindow(image_reader.get(), &raw))); + ANativeWindow_acquire(native_window.get()); + + CREATE(ACaptureSessionOutput, output, + CAMERA_CALL(ACaptureSessionOutput_create(native_window.get(), &raw))); + + CREATE(ACaptureSessionOutputContainer, outputs, + CAMERA_CALL(ACaptureSessionOutputContainer_create(&raw))); + CAMERA_CALL(ACaptureSessionOutputContainer_add(outputs.get(), output.get())); + + session_callbacks = { + /*context*/ nullptr, + /*onClosed*/ &OnClosed, + /*onReady*/ &OnReady, + /*onActive*/ &OnActive, + }; + CREATE(ACameraCaptureSession, session, + CAMERA_CALL(ACameraDevice_createCaptureSession(device.get(), outputs.get(), + &session_callbacks, &raw))); + CREATE(ACaptureRequest, request, + CAMERA_CALL(ACameraDevice_createCaptureRequest(device.get(), TEMPLATE_PREVIEW, &raw))); + + CREATE(ACameraOutputTarget, target, + CAMERA_CALL(ACameraOutputTarget_create(native_window.get(), &raw))); + CAMERA_CALL(ACaptureRequest_addTarget(request.get(), target.get())); + + requests = {request.get()}; + CAMERA_CALL(ACameraCaptureSession_setRepeatingRequest(session.get(), nullptr, 1, + requests.data(), nullptr)); + + // Wait until the first image comes + active_event.Wait(); + { + std::lock_guard lock{status_mutex}; + ready = true; + } +} + +#undef MEDIA_CALL +#undef CAMERA_CALL +#undef CREATE + +Interface::Interface(Factory& factory_, const std::string& id_, const Service::CAM::Flip& flip) + : factory(factory_), id(id_) { + mirror = base_mirror = + flip == Service::CAM::Flip::Horizontal || flip == Service::CAM::Flip::Reverse; + invert = base_invert = + flip == Service::CAM::Flip::Vertical || flip == Service::CAM::Flip::Reverse; +} + +Interface::~Interface() { + factory.camera_permission_requested = false; +} + +void Interface::StartCapture() { + session = factory.CreateCaptureSession(id); +} + +void Interface::StopCapture() { + session.reset(); +} + +void Interface::SetResolution(const Service::CAM::Resolution& resolution_) { + resolution = resolution_; +} + +void Interface::SetFlip(Service::CAM::Flip flip) { + mirror = base_mirror ^ + (flip == Service::CAM::Flip::Horizontal || flip == Service::CAM::Flip::Reverse); + invert = + base_invert ^ (flip == Service::CAM::Flip::Vertical || flip == Service::CAM::Flip::Reverse); +} + +void Interface::SetFormat(Service::CAM::OutputFormat format_) { + format = format_; +} + +struct YUVImage { + int width{}; + int height{}; + std::vector y; + std::vector u; + std::vector v; + + explicit YUVImage(int width_, int height_) + : width(width_), height(height_), y(static_cast(width * height)), + u(static_cast(width * height / 4)), + v(static_cast(width * height / 4)) {} + + void Swap(YUVImage& other) { + y.swap(other.y); + u.swap(other.u); + v.swap(other.v); + std::swap(width, other.width); + std::swap(height, other.height); + } + + void Clear() { + y.clear(); + u.clear(); + v.clear(); + width = height = 0; + } +}; + +#define YUV(image) \ + image.y.data(), image.width, image.u.data(), image.width / 2, image.v.data(), image.width / 2 + +std::vector Interface::ReceiveFrame() { + bool should_reload = false; + { + std::lock_guard lock{session->status_mutex}; + if (session->reload_requested) { + session->reload_requested = false; + should_reload = session->disconnected; + } + } + if (should_reload) { + LOG_INFO(Service_CAM, "Reloading camera session"); + session->Load(factory.manager.get(), id); + } + + bool session_ready; + { + std::lock_guard lock{session->status_mutex}; + session_ready = session->ready; + } + if (!session_ready) { // Camera was not opened + return std::vector(resolution.width * resolution.height); + } + + std::array, 3> data; + std::array row_stride; + { + std::lock_guard lock{session->data_mutex}; + data = session->data; + row_stride = session->row_stride; + } + + ASSERT_MSG(data[0] && data[1] && data[2], "Data is not available"); + + auto [width, height] = session->selected_resolution; + + YUVImage converted(width, height); + libyuv::Android420ToI420(data[0].get(), row_stride[0], data[1].get(), row_stride[1], + data[2].get(), row_stride[2], session->pixel_stride, YUV(converted), + width, height); + + // Rotate the image to get it in upright position + // The g_rotation here is the display rotation which is opposite of the device rotation + const int rotation = + (session->sensor_orientation + (session->facing_front ? g_rotation : 4 - g_rotation) * 90) % + 360; + if (rotation == 90 || rotation == 270) { + std::swap(width, height); + } + YUVImage rotated(width, height); + libyuv::I420Rotate(YUV(converted), YUV(rotated), converted.width, converted.height, + static_cast(rotation)); + converted.Clear(); + + // Calculate crop coordinates + int crop_width{}, crop_height{}; + if (resolution.width * height > resolution.height * width) { + crop_width = width; + crop_height = width * resolution.height / resolution.width; + } else { + crop_height = height; + crop_width = height * resolution.width / resolution.height; + } + const int crop_x = (width - crop_width) / 2; + const int crop_y = (height - crop_height) / 2; + + const int y_offset = crop_y * width + crop_x; + const int uv_offset = crop_y / 2 * width / 2 + crop_x / 2; + YUVImage scaled(resolution.width, resolution.height); + // Crop and scale + libyuv::I420Scale(rotated.y.data() + y_offset, width, rotated.u.data() + uv_offset, width / 2, + rotated.v.data() + uv_offset, width / 2, crop_width, crop_height, YUV(scaled), + resolution.width, resolution.height, libyuv::kFilterBilinear); + rotated.Clear(); + + if (mirror) { + YUVImage mirrored(scaled.width, scaled.height); + libyuv::I420Mirror(YUV(scaled), YUV(mirrored), resolution.width, resolution.height); + scaled.Swap(mirrored); + } + + std::vector output(resolution.width * resolution.height); + if (format == Service::CAM::OutputFormat::RGB565) { + libyuv::I420ToRGB565(YUV(scaled), reinterpret_cast(output.data()), + resolution.width * 2, resolution.width, + invert ? -resolution.height : resolution.height); + } else { + libyuv::I420ToYUY2(YUV(scaled), reinterpret_cast(output.data()), resolution.width * 2, + resolution.width, invert ? -resolution.height : resolution.height); + } + return output; +} + +#undef YUV + +bool Interface::IsPreviewAvailable() { + if (!session) { + return false; + } + std::lock_guard lock{session->status_mutex}; + return session->ready; +} + +Factory::Factory() = default; + +Factory::~Factory() = default; + +std::shared_ptr Factory::CreateCaptureSession(const std::string& id) { + if (opened_camera_map.count(id) && !opened_camera_map.at(id).expired()) { + return opened_camera_map.at(id).lock(); + } + const auto& session = std::make_shared(manager.get(), id); + opened_camera_map.insert_or_assign(id, session); + return session; +} + +std::unique_ptr Factory::Create(const std::string& config, + const Service::CAM::Flip& flip) { + + manager.reset(ACameraManager_create()); + ACameraIdList* id_list = nullptr; + + auto ret = ACameraManager_getCameraIdList(manager.get(), &id_list); + if (ret != ACAMERA_OK) { + LOG_ERROR(Service_CAM, "Failed to get camera ID list: ret {}", ret); + return std::make_unique(); + } + + SCOPE_EXIT({ ACameraManager_deleteCameraIdList(id_list); }); + + if (id_list->numCameras <= 0) { + LOG_WARNING(Service_CAM, "No camera devices found, falling back to StillImage"); + // TODO: A better way of doing this? + return std::make_unique()->Create("", flip); + } + + // Request camera permission + if (!camera_permission_granted) { + if (camera_permission_requested) { // Permissions already denied + return std::make_unique(); + } + camera_permission_requested = true; + + JNIEnv* env = IDCache::GetEnvForThread(); + jboolean result = env->CallStaticBooleanMethod(IDCache::GetNativeLibraryClass(), + IDCache::GetRequestCameraPermission()); + if (result != JNI_TRUE) { + LOG_ERROR(Service_CAM, "Camera permissions denied"); + return std::make_unique(); + } + camera_permission_granted = true; + } + + if (config.empty()) { + LOG_WARNING(Service_CAM, "Camera ID not set, using default camera"); + return std::make_unique(*this, id_list->cameraIds[0], flip); + } + + for (int i = 0; i < id_list->numCameras; ++i) { + const char* id = id_list->cameraIds[i]; + if (config == id) { + return std::make_unique(*this, id, flip); + } + + if (config != FrontCameraPlaceholder && config != BackCameraPlaceholder) { + continue; + } + + ACameraMetadata* metadata; + ACameraManager_getCameraCharacteristics(manager.get(), id, &metadata); + SCOPE_EXIT({ ACameraMetadata_free(metadata); }); + + ACameraMetadata_const_entry entry; + ACameraMetadata_getConstEntry(metadata, ACAMERA_LENS_FACING, &entry); + if ((entry.data.i32[0] == ACAMERA_LENS_FACING_FRONT && config == FrontCameraPlaceholder) || + (entry.data.i32[0] == ACAMERA_LENS_FACING_BACK && config == BackCameraPlaceholder)) { + return std::make_unique(*this, id, flip); + } + } + + LOG_ERROR(Service_CAM, "Camera ID {} not found", config); + return std::make_unique(); +} + +void Factory::ReloadCameraDevices() { + for (const auto& [id, ptr] : opened_camera_map) { + if (auto session = ptr.lock()) { + std::lock_guard lock{session->status_mutex}; + session->reload_requested = true; + } + } +} + +} // namespace Camera::NDK diff --git a/src/android/app/src/main/jni/camera/ndk_camera.h b/src/android/app/src/main/jni/camera/ndk_camera.h new file mode 100644 index 000000000..4b9bbfdaa --- /dev/null +++ b/src/android/app/src/main/jni/camera/ndk_camera.h @@ -0,0 +1,91 @@ +// Copyright 2020 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#pragma once + +#include +#include +#include +#include +#include "common/common_types.h" +#include "core/frontend/camera/factory.h" +#include "core/frontend/camera/interface.h" +#include "core/hle/service/cam/cam.h" + +namespace Camera::NDK { + +struct CaptureSession; +class Factory; + +class Interface : public CameraInterface { +public: + Interface(Factory& factory, const std::string& id, const Service::CAM::Flip& flip); + ~Interface() override; + void StartCapture() override; + void StopCapture() override; + void SetResolution(const Service::CAM::Resolution& resolution) override; + void SetFlip(Service::CAM::Flip flip) override; + void SetEffect(Service::CAM::Effect effect) override{}; + void SetFormat(Service::CAM::OutputFormat format) override; + void SetFrameRate(Service::CAM::FrameRate frame_rate) override{}; + std::vector ReceiveFrame() override; + bool IsPreviewAvailable() override; + +private: + Factory& factory; + std::shared_ptr session; + std::string id; + + Service::CAM::Resolution resolution; + + // Flipping parameters. mirror = horizontal, invert = vertical. + bool base_mirror{}; + bool base_invert{}; + bool mirror{}; + bool invert{}; + + Service::CAM::OutputFormat format; +}; + +// Placeholders to mean 'use any front/back camera' +constexpr std::string_view FrontCameraPlaceholder = "_front"; +constexpr std::string_view BackCameraPlaceholder = "_back"; + +class Factory final : public CameraFactory { +public: + explicit Factory(); + ~Factory() override; + + std::unique_ptr Create(const std::string& config, + const Service::CAM::Flip& flip) override; + + // Request the reopening of all previously disconnected camera devices. + // Called when the application is brought to foreground (i.e. we have priority with the camera) + void ReloadCameraDevices(); + +private: + // Avoid requesting for permission more than once on each call + bool camera_permission_requested = false; + bool camera_permission_granted = false; + + std::shared_ptr CreateCaptureSession(const std::string& id); + + // The session is cached, to avoid opening the same camera twice. + // This is weak_ptr so that the session is destructed when all cameras are closed + std::unordered_map> opened_camera_map; + + struct ACameraManagerDeleter { + void operator()(ACameraManager* manager) { + ACameraManager_delete(manager); + } + }; + std::unique_ptr manager; + + friend class Interface; +}; + +// Device rotation. Updated in native.cpp. +inline int g_rotation = 0; + +} // namespace Camera::NDK diff --git a/src/android/app/src/main/jni/camera/still_image_camera.cpp b/src/android/app/src/main/jni/camera/still_image_camera.cpp new file mode 100644 index 000000000..496b51fa8 --- /dev/null +++ b/src/android/app/src/main/jni/camera/still_image_camera.cpp @@ -0,0 +1,143 @@ +// Copyright 2020 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#include +#include +#include "common/logging/log.h" +#include "core/frontend/camera/blank_camera.h" +#include "jni/camera/still_image_camera.h" + +static jclass s_still_image_camera_helper_class; +static jmethodID s_open_file_picker; +static jmethodID s_load_image_from_file; + +namespace Camera::StillImage { + +void InitJNI(JNIEnv* env) { + s_still_image_camera_helper_class = reinterpret_cast( + env->NewGlobalRef(env->FindClass("org/citra/citra_emu/camera/StillImageCameraHelper"))); + s_open_file_picker = env->GetStaticMethodID(s_still_image_camera_helper_class, "OpenFilePicker", + "()Ljava/lang/String;"); + s_load_image_from_file = + env->GetStaticMethodID(s_still_image_camera_helper_class, "LoadImageFromFile", + "(Ljava/lang/String;II)Landroid/graphics/Bitmap;"); +} + +void CleanupJNI(JNIEnv* env) { + env->DeleteGlobalRef(s_still_image_camera_helper_class); +} + +Interface::Interface(SharedGlobalRef path_, const Service::CAM::Flip& flip) + : path(std::move(path_)) { + mirror = base_mirror = + flip == Service::CAM::Flip::Horizontal || flip == Service::CAM::Flip::Reverse; + invert = base_invert = + flip == Service::CAM::Flip::Vertical || flip == Service::CAM::Flip::Reverse; +} + +Interface::~Interface() { + Factory::last_path.reset(); +} + +void Interface::StartCapture() { + JNIEnv* env = IDCache::GetEnvForThread(); + jobject bitmap = + env->CallStaticObjectMethod(s_still_image_camera_helper_class, s_load_image_from_file, + path.get(), resolution.width, resolution.height); + if (bitmap == nullptr) { + LOG_ERROR(Frontend, "Could not load image from file"); + opened = false; + return; + } + + int ret; + +#define BITMAP_CALL(func) \ + ret = AndroidBitmap_##func; \ + if (ret != ANDROID_BITMAP_RESULT_SUCCESS) { \ + LOG_ERROR(Frontend, #func " failed with code {}", ret); \ + opened = false; \ + return; \ + } + + AndroidBitmapInfo info; + BITMAP_CALL(getInfo(env, bitmap, &info)); + ASSERT_MSG(info.format == AndroidBitmapFormat::ANDROID_BITMAP_FORMAT_RGBA_8888, + "Bitmap format was incorrect"); + ASSERT_MSG(info.width == resolution.width && info.height == resolution.height, + "Bitmap resolution was incorrect"); + + void* raw_data; + BITMAP_CALL(lockPixels(env, bitmap, &raw_data)); + std::vector data(info.height * info.stride); + libyuv::ABGRToARGB(reinterpret_cast(raw_data), info.stride, data.data(), info.stride, + info.width, info.height); + BITMAP_CALL(unlockPixels(env, bitmap)); + + if (mirror) { + std::vector mirrored(data.size()); + libyuv::ARGBMirror(data.data(), info.stride, mirrored.data(), info.stride, info.width, + info.height); + data.swap(mirrored); + } + + image.resize(info.height * info.width); + if (format == Service::CAM::OutputFormat::RGB565) { + libyuv::ARGBToRGB565(data.data(), info.stride, reinterpret_cast(image.data()), + info.width * 2, info.width, invert ? -info.height : info.height); + } else { + libyuv::ARGBToYUY2(data.data(), info.stride, reinterpret_cast(image.data()), + info.width * 2, info.width, invert ? -info.height : info.height); + } + opened = true; + +#undef BITMAP_CALL +} + +void Interface::SetResolution(const Service::CAM::Resolution& resolution_) { + resolution = resolution_; +} + +void Interface::SetFlip(Service::CAM::Flip flip) { + mirror = base_mirror ^ + (flip == Service::CAM::Flip::Horizontal || flip == Service::CAM::Flip::Reverse); + invert = + base_invert ^ (flip == Service::CAM::Flip::Vertical || flip == Service::CAM::Flip::Reverse); +} + +void Interface::SetFormat(Service::CAM::OutputFormat format_) { + format = format_; +} + +std::vector Interface::ReceiveFrame() { + return image; +} + +bool Interface::IsPreviewAvailable() { + return opened; +} + +SharedGlobalRef Factory::last_path{}; + +std::unique_ptr Factory::Create(const std::string& config, + const Service::CAM::Flip& flip) { + + JNIEnv* env = IDCache::GetEnvForThread(); + if (last_path != nullptr) { + return std::make_unique(last_path, flip); + } + + // Open file picker to get the string + jstring path = reinterpret_cast( + env->CallStaticObjectMethod(s_still_image_camera_helper_class, s_open_file_picker)); + if (path == nullptr) { + return std::make_unique(); + } else { + auto shared_path = NewSharedGlobalRef(path); + last_path = shared_path; + return std::make_unique(std::move(shared_path), flip); + } +} + +} // namespace Camera::StillImage diff --git a/src/android/app/src/main/jni/camera/still_image_camera.h b/src/android/app/src/main/jni/camera/still_image_camera.h new file mode 100644 index 000000000..ab12040a3 --- /dev/null +++ b/src/android/app/src/main/jni/camera/still_image_camera.h @@ -0,0 +1,63 @@ +// Copyright 2020 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#pragma once + +#include +#include +#include +#include +#include "common/common_types.h" +#include "core/frontend/camera/factory.h" +#include "core/frontend/camera/interface.h" +#include "core/hle/service/cam/cam.h" +#include "jni/id_cache.h" + +namespace Camera::StillImage { + +class Interface final : public CameraInterface { +public: + Interface(SharedGlobalRef path, const Service::CAM::Flip& flip); + ~Interface(); + void StartCapture() override; + void StopCapture() override{}; + void SetResolution(const Service::CAM::Resolution& resolution) override; + void SetFlip(Service::CAM::Flip flip) override; + void SetEffect(Service::CAM::Effect effect) override{}; + void SetFormat(Service::CAM::OutputFormat format) override; + void SetFrameRate(Service::CAM::FrameRate frame_rate) override{}; + std::vector ReceiveFrame() override; + bool IsPreviewAvailable() override; + +private: + SharedGlobalRef path; + Service::CAM::Resolution resolution; + + // Flipping parameters. mirror = horizontal, invert = vertical. + bool base_mirror{}; + bool base_invert{}; + bool mirror{}; + bool invert{}; + + Service::CAM::OutputFormat format; + std::vector image; // Data fetched from the frontend + bool opened{}; // Whether the camera was successfully opened +}; + +class Factory final : public CameraFactory { +public: + std::unique_ptr Create(const std::string& config, + const Service::CAM::Flip& flip) override; + +private: + /// Record the path chosen to avoid multiple prompt problem + static SharedGlobalRef last_path; + + friend class Interface; +}; + +void InitJNI(JNIEnv* env); +void CleanupJNI(JNIEnv* env); + +} // namespace Camera::StillImage diff --git a/src/android/app/src/main/jni/config.cpp b/src/android/app/src/main/jni/config.cpp new file mode 100644 index 000000000..1c9aa7cc9 --- /dev/null +++ b/src/android/app/src/main/jni/config.cpp @@ -0,0 +1,277 @@ +// Copyright 2014 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#include +#include +#include +#include +#include + +#include "common/file_util.h" +#include "common/logging/log.h" +#include "common/param_package.h" +#include "core/core.h" +#include "core/hle/service/cfg/cfg.h" +#include "core/hle/service/service.h" +#include "core/settings.h" +#include "input_common/main.h" +#include "input_common/udp/client.h" +#include "jni/camera/ndk_camera.h" +#include "jni/config.h" +#include "jni/default_ini.h" +#include "jni/input_manager.h" + +Config::Config() { + // TODO: Don't hardcode the path; let the frontend decide where to put the config files. + sdl2_config_loc = FileUtil::GetUserPath(FileUtil::UserPath::ConfigDir) + "config.ini"; + sdl2_config = std::make_unique(sdl2_config_loc); + + Reload(); +} + +Config::~Config() = default; + +bool Config::LoadINI(const std::string& default_contents, bool retry) { + const std::string& location = this->sdl2_config_loc; + if (sdl2_config->ParseError() < 0) { + if (retry) { + LOG_WARNING(Config, "Failed to load {}. Creating file from defaults...", location); + FileUtil::CreateFullPath(location); + FileUtil::WriteStringToFile(true, location, default_contents); + sdl2_config = std::make_unique(location); // Reopen file + + return LoadINI(default_contents, false); + } + LOG_ERROR(Config, "Failed."); + return false; + } + LOG_INFO(Config, "Successfully loaded {}", location); + return true; +} + +static const std::array default_buttons = { + InputManager::N3DS_BUTTON_A, InputManager::N3DS_BUTTON_B, + InputManager::N3DS_BUTTON_X, InputManager::N3DS_BUTTON_Y, + InputManager::N3DS_DPAD_UP, InputManager::N3DS_DPAD_DOWN, + InputManager::N3DS_DPAD_LEFT, InputManager::N3DS_DPAD_RIGHT, + InputManager::N3DS_TRIGGER_L, InputManager::N3DS_TRIGGER_R, + InputManager::N3DS_BUTTON_START, InputManager::N3DS_BUTTON_SELECT, + InputManager::N3DS_BUTTON_DEBUG, InputManager::N3DS_BUTTON_GPIO14, + InputManager::N3DS_BUTTON_ZL, InputManager::N3DS_BUTTON_ZR, + InputManager::N3DS_BUTTON_HOME, +}; + +static const std::array default_analogs{{ + InputManager::N3DS_CIRCLEPAD, + InputManager::N3DS_STICK_C, +}}; + +void Config::UpdateCFG() { + std::shared_ptr cfg = std::make_shared(); + cfg->SetSystemLanguage(static_cast( + sdl2_config->GetInteger("System", "language", Service::CFG::SystemLanguage::LANGUAGE_EN))); + cfg->UpdateConfigNANDSavegame(); +} + +void Config::ReadValues() { + // Controls + for (int i = 0; i < Settings::NativeButton::NumButtons; ++i) { + std::string default_param = InputManager::GenerateButtonParamPackage(default_buttons[i]); + Settings::values.current_input_profile.buttons[i] = + sdl2_config->GetString("Controls", Settings::NativeButton::mapping[i], default_param); + if (Settings::values.current_input_profile.buttons[i].empty()) + Settings::values.current_input_profile.buttons[i] = default_param; + } + + for (int i = 0; i < Settings::NativeAnalog::NumAnalogs; ++i) { + std::string default_param = InputManager::GenerateAnalogParamPackage(default_analogs[i]); + Settings::values.current_input_profile.analogs[i] = + sdl2_config->GetString("Controls", Settings::NativeAnalog::mapping[i], default_param); + if (Settings::values.current_input_profile.analogs[i].empty()) + Settings::values.current_input_profile.analogs[i] = default_param; + } + + Settings::values.current_input_profile.motion_device = sdl2_config->GetString( + "Controls", "motion_device", + "engine:motion_emu,update_period:100,sensitivity:0.01,tilt_clamp:90.0"); + Settings::values.current_input_profile.touch_device = + sdl2_config->GetString("Controls", "touch_device", "engine:emu_window"); + Settings::values.current_input_profile.udp_input_address = sdl2_config->GetString( + "Controls", "udp_input_address", InputCommon::CemuhookUDP::DEFAULT_ADDR); + Settings::values.current_input_profile.udp_input_port = + static_cast(sdl2_config->GetInteger("Controls", "udp_input_port", + InputCommon::CemuhookUDP::DEFAULT_PORT)); + + // Core + Settings::values.use_cpu_jit = sdl2_config->GetBoolean("Core", "use_cpu_jit", true); + Settings::values.cpu_clock_percentage = + static_cast(sdl2_config->GetInteger("Core", "cpu_clock_percentage", 100)); + + // Premium + Settings::values.texture_filter_name = + sdl2_config->GetString("Premium", "texture_filter_name", "none"); + + // Renderer + Settings::values.use_gles = sdl2_config->GetBoolean("Renderer", "use_gles", true); + Settings::values.use_hw_renderer = sdl2_config->GetBoolean("Renderer", "use_hw_renderer", true); + Settings::values.use_hw_shader = sdl2_config->GetBoolean("Renderer", "use_hw_shader", true); + Settings::values.shaders_accurate_mul = + sdl2_config->GetBoolean("Renderer", "shaders_accurate_mul", false); + Settings::values.use_shader_jit = sdl2_config->GetBoolean("Renderer", "use_shader_jit", true); + Settings::values.resolution_factor = + static_cast(sdl2_config->GetInteger("Renderer", "resolution_factor", 1)); + Settings::values.use_disk_shader_cache = + sdl2_config->GetBoolean("Renderer", "use_disk_shader_cache", true); + Settings::values.use_vsync_new = sdl2_config->GetBoolean("Renderer", "use_vsync_new", true); + + // Work around to map Android setting for enabling the frame limiter to the format Citra expects + if (sdl2_config->GetBoolean("Renderer", "use_frame_limit", true)) { + Settings::values.frame_limit = + static_cast(sdl2_config->GetInteger("Renderer", "frame_limit", 100)); + } else { + Settings::values.frame_limit = 0; + } + + Settings::values.render_3d = static_cast( + sdl2_config->GetInteger("Renderer", "render_3d", 0)); + Settings::values.factor_3d = + static_cast(sdl2_config->GetInteger("Renderer", "factor_3d", 0)); + std::string default_shader = "none (builtin)"; + if (Settings::values.render_3d == Settings::StereoRenderOption::Anaglyph) + default_shader = "dubois (builtin)"; + else if (Settings::values.render_3d == Settings::StereoRenderOption::Interlaced) + default_shader = "horizontal (builtin)"; + Settings::values.pp_shader_name = + sdl2_config->GetString("Renderer", "pp_shader_name", default_shader); + Settings::values.filter_mode = sdl2_config->GetBoolean("Renderer", "filter_mode", true); + + Settings::values.bg_red = static_cast(sdl2_config->GetReal("Renderer", "bg_red", 0.0)); + Settings::values.bg_green = + static_cast(sdl2_config->GetReal("Renderer", "bg_green", 0.0)); + Settings::values.bg_blue = static_cast(sdl2_config->GetReal("Renderer", "bg_blue", 0.0)); + + // Layout + Settings::values.layout_option = static_cast(sdl2_config->GetInteger( + "Layout", "layout_option", static_cast(Settings::LayoutOption::MobileLandscape))); + Settings::values.custom_layout = sdl2_config->GetBoolean("Layout", "custom_layout", false); + Settings::values.custom_top_left = + static_cast(sdl2_config->GetInteger("Layout", "custom_top_left", 0)); + Settings::values.custom_top_top = + static_cast(sdl2_config->GetInteger("Layout", "custom_top_top", 0)); + Settings::values.custom_top_right = + static_cast(sdl2_config->GetInteger("Layout", "custom_top_right", 400)); + Settings::values.custom_top_bottom = + static_cast(sdl2_config->GetInteger("Layout", "custom_top_bottom", 240)); + Settings::values.custom_bottom_left = + static_cast(sdl2_config->GetInteger("Layout", "custom_bottom_left", 40)); + Settings::values.custom_bottom_top = + static_cast(sdl2_config->GetInteger("Layout", "custom_bottom_top", 240)); + Settings::values.custom_bottom_right = + static_cast(sdl2_config->GetInteger("Layout", "custom_bottom_right", 360)); + Settings::values.custom_bottom_bottom = + static_cast(sdl2_config->GetInteger("Layout", "custom_bottom_bottom", 480)); + Settings::values.cardboard_screen_size = + static_cast(sdl2_config->GetInteger("Layout", "cardboard_screen_size", 85)); + Settings::values.cardboard_x_shift = + static_cast(sdl2_config->GetInteger("Layout", "cardboard_x_shift", 0)); + Settings::values.cardboard_y_shift = + static_cast(sdl2_config->GetInteger("Layout", "cardboard_y_shift", 0)); + + // Audio + Settings::values.enable_dsp_lle = sdl2_config->GetBoolean("Audio", "enable_dsp_lle", false); + Settings::values.enable_dsp_lle_multithread = + sdl2_config->GetBoolean("Audio", "enable_dsp_lle_multithread", false); + Settings::values.sink_id = sdl2_config->GetString("Audio", "output_engine", "auto"); + Settings::values.enable_audio_stretching = + sdl2_config->GetBoolean("Audio", "enable_audio_stretching", true); + Settings::values.audio_device_id = sdl2_config->GetString("Audio", "output_device", "auto"); + Settings::values.volume = static_cast(sdl2_config->GetReal("Audio", "volume", 1)); + Settings::values.mic_input_device = + sdl2_config->GetString("Audio", "mic_input_device", "Default"); + Settings::values.mic_input_type = + static_cast(sdl2_config->GetInteger("Audio", "mic_input_type", 1)); + + // Data Storage + Settings::values.use_virtual_sd = + sdl2_config->GetBoolean("Data Storage", "use_virtual_sd", true); + + // System + Settings::values.is_new_3ds = sdl2_config->GetBoolean("System", "is_new_3ds", true); + Settings::values.region_value = + sdl2_config->GetInteger("System", "region_value", Settings::REGION_VALUE_AUTO_SELECT); + Settings::values.init_clock = + static_cast(sdl2_config->GetInteger("System", "init_clock", 0)); + { + std::tm t; + t.tm_sec = 1; + t.tm_min = 0; + t.tm_hour = 0; + t.tm_mday = 1; + t.tm_mon = 0; + t.tm_year = 100; + t.tm_isdst = 0; + std::istringstream string_stream( + sdl2_config->GetString("System", "init_time", "2000-01-01 00:00:01")); + string_stream >> std::get_time(&t, "%Y-%m-%d %H:%M:%S"); + if (string_stream.fail()) { + LOG_ERROR(Config, "Failed To parse init_time. Using 2000-01-01 00:00:01"); + } + Settings::values.init_time = + std::chrono::duration_cast( + std::chrono::system_clock::from_time_t(std::mktime(&t)).time_since_epoch()) + .count(); + } + + // Camera + using namespace Service::CAM; + Settings::values.camera_name[OuterRightCamera] = + sdl2_config->GetString("Camera", "camera_outer_right_name", "ndk"); + Settings::values.camera_config[OuterRightCamera] = sdl2_config->GetString( + "Camera", "camera_outer_right_config", std::string{Camera::NDK::BackCameraPlaceholder}); + Settings::values.camera_flip[OuterRightCamera] = + sdl2_config->GetInteger("Camera", "camera_outer_right_flip", 0); + Settings::values.camera_name[InnerCamera] = + sdl2_config->GetString("Camera", "camera_inner_name", "ndk"); + Settings::values.camera_config[InnerCamera] = sdl2_config->GetString( + "Camera", "camera_inner_config", std::string{Camera::NDK::FrontCameraPlaceholder}); + Settings::values.camera_flip[InnerCamera] = + sdl2_config->GetInteger("Camera", "camera_inner_flip", 0); + Settings::values.camera_name[OuterLeftCamera] = + sdl2_config->GetString("Camera", "camera_outer_left_name", "ndk"); + Settings::values.camera_config[OuterLeftCamera] = sdl2_config->GetString( + "Camera", "camera_outer_left_config", std::string{Camera::NDK::BackCameraPlaceholder}); + Settings::values.camera_flip[OuterLeftCamera] = + sdl2_config->GetInteger("Camera", "camera_outer_left_flip", 0); + + // Miscellaneous + Settings::values.log_filter = sdl2_config->GetString("Miscellaneous", "log_filter", "*:Info"); + + // Debugging + Settings::values.record_frame_times = + sdl2_config->GetBoolean("Debugging", "record_frame_times", false); + Settings::values.use_gdbstub = sdl2_config->GetBoolean("Debugging", "use_gdbstub", false); + Settings::values.gdbstub_port = + static_cast(sdl2_config->GetInteger("Debugging", "gdbstub_port", 24689)); + + for (const auto& service_module : Service::service_module_map) { + bool use_lle = sdl2_config->GetBoolean("Debugging", "LLE\\" + service_module.name, false); + Settings::values.lle_modules.emplace(service_module.name, use_lle); + } + + // Web Service + Settings::values.enable_telemetry = + sdl2_config->GetBoolean("WebService", "enable_telemetry", true); + Settings::values.web_api_url = + sdl2_config->GetString("WebService", "web_api_url", "https://api.citra-emu.org"); + Settings::values.citra_username = sdl2_config->GetString("WebService", "citra_username", ""); + Settings::values.citra_token = sdl2_config->GetString("WebService", "citra_token", ""); + + // Update CFG file based on settings + UpdateCFG(); +} + +void Config::Reload() { + LoadINI(DefaultINI::sdl2_config_file); + ReadValues(); +} diff --git a/src/android/app/src/main/jni/config.h b/src/android/app/src/main/jni/config.h new file mode 100644 index 000000000..465457856 --- /dev/null +++ b/src/android/app/src/main/jni/config.h @@ -0,0 +1,26 @@ +// Copyright 2014 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#pragma once + +#include +#include + +class INIReader; + +class Config { +private: + std::unique_ptr sdl2_config; + std::string sdl2_config_loc; + + bool LoadINI(const std::string& default_contents = "", bool retry = true); + void ReadValues(); + void UpdateCFG(); + +public: + Config(); + ~Config(); + + void Reload(); +}; diff --git a/src/android/app/src/main/jni/default_ini.h b/src/android/app/src/main/jni/default_ini.h new file mode 100644 index 000000000..56ab75e1a --- /dev/null +++ b/src/android/app/src/main/jni/default_ini.h @@ -0,0 +1,323 @@ +// Copyright 2014 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#pragma once + +namespace DefaultINI { + +const char* sdl2_config_file = R"( +[Controls] +# The input devices and parameters for each 3DS native input +# It should be in the format of "engine:[engine_name],[param1]:[value1],[param2]:[value2]..." +# Escape characters $0 (for ':'), $1 (for ',') and $2 (for '$') can be used in values + +# for button input, the following devices are available: +# - "keyboard" (default) for keyboard input. Required parameters: +# - "code": the code of the key to bind +# - "sdl" for joystick input using SDL. Required parameters: +# - "joystick": the index of the joystick to bind +# - "button"(optional): the index of the button to bind +# - "hat"(optional): the index of the hat to bind as direction buttons +# - "axis"(optional): the index of the axis to bind +# - "direction"(only used for hat): the direction name of the hat to bind. Can be "up", "down", "left" or "right" +# - "threshold"(only used for axis): a float value in (-1.0, 1.0) which the button is +# triggered if the axis value crosses +# - "direction"(only used for axis): "+" means the button is triggered when the axis value +# is greater than the threshold; "-" means the button is triggered when the axis value +# is smaller than the threshold +button_a= +button_b= +button_x= +button_y= +button_up= +button_down= +button_left= +button_right= +button_l= +button_r= +button_start= +button_select= +button_debug= +button_gpio14= +button_zl= +button_zr= +button_home= + +# for analog input, the following devices are available: +# - "analog_from_button" (default) for emulating analog input from direction buttons. Required parameters: +# - "up", "down", "left", "right": sub-devices for each direction. +# Should be in the format as a button input devices using escape characters, for example, "engine$0keyboard$1code$00" +# - "modifier": sub-devices as a modifier. +# - "modifier_scale": a float number representing the applied modifier scale to the analog input. +# Must be in range of 0.0-1.0. Defaults to 0.5 +# - "sdl" for joystick input using SDL. Required parameters: +# - "joystick": the index of the joystick to bind +# - "axis_x": the index of the axis to bind as x-axis (default to 0) +# - "axis_y": the index of the axis to bind as y-axis (default to 1) +circle_pad= +c_stick= + +# for motion input, the following devices are available: +# - "motion_emu" (default) for emulating motion input from mouse input. Required parameters: +# - "update_period": update period in milliseconds (default to 100) +# - "sensitivity": the coefficient converting mouse movement to tilting angle (default to 0.01) +# - "tilt_clamp": the max value of the tilt angle in degrees (default to 90) +# - "cemuhookudp" reads motion input from a udp server that uses cemuhook's udp protocol +motion_device= + +# for touch input, the following devices are available: +# - "emu_window" (default) for emulating touch input from mouse input to the emulation window. No parameters required +# - "cemuhookudp" reads touch input from a udp server that uses cemuhook's udp protocol +# - "min_x", "min_y", "max_x", "max_y": defines the udp device's touch screen coordinate system +touch_device= engine:emu_window + +# Most desktop operating systems do not expose a way to poll the motion state of the controllers +# so as a way around it, cemuhook created a udp client/server protocol to broadcast the data directly +# from a controller device to the client program. Citra has a client that can connect and read +# from any cemuhook compatible motion program. + +# IPv4 address of the udp input server (Default "127.0.0.1") +udp_input_address= + +# Port of the udp input server. (Default 26760) +udp_input_port= + +# The pad to request data on. Should be between 0 (Pad 1) and 3 (Pad 4). (Default 0) +udp_pad_index= + +[Core] +# Whether to use the Just-In-Time (JIT) compiler for CPU emulation +# 0: Interpreter (slow), 1 (default): JIT (fast) +use_cpu_jit = + +# Change the Clock Frequency of the emulated 3DS CPU. +# Underclocking can increase the performance of the game at the risk of freezing. +# Overclocking may fix lag that happens on console, but also comes with the risk of freezing. +# Range is any positive integer (but we suspect 25 - 400 is a good idea) Default is 100 +cpu_clock_percentage = + +[Renderer] +# Whether to render using GLES or OpenGL +# 0: OpenGL, 1 (default): GLES +use_gles = + +# Whether to use software or hardware rendering. +# 0: Software, 1 (default): Hardware +use_hw_renderer = + +# Whether to use hardware shaders to emulate 3DS shaders +# 0: Software, 1 (default): Hardware +use_hw_shader = + +# Whether to use separable shaders to emulate 3DS shaders (macOS only) +# 0: Off (Default), 1 : On +separable_shader = + +# Whether to use accurate multiplication in hardware shaders +# 0: Off (Default. Faster, but causes issues in some games) 1: On (Slower, but correct) +shaders_accurate_mul = + +# Enable asynchronous GPU emulation +# 0: Off (Slower, but more accurate) 1: On (Default. Faster, but may cause issues in some games) +use_asynchronous_gpu_emulation = + +# Whether to use the Just-In-Time (JIT) compiler for shader emulation +# 0: Interpreter (slow), 1 (default): JIT (fast) +use_shader_jit = + +# Forces VSync on the display thread. Usually doesn't impact performance, but on some drivers it can +# so only turn this off if you notice a speed difference. +# 0: Off, 1 (default): On +use_vsync_new = + +# Reduce stuttering by storing and loading generated shaders to disk +# 0: Off, 1 (default. On) +use_disk_shader_cache = + +# Resolution scale factor +# 0: Auto (scales resolution to window size), 1: Native 3DS screen resolution, Otherwise a scale +# factor for the 3DS resolution +resolution_factor = + +# Whether to enable V-Sync (caps the framerate at 60FPS) or not. +# 0 (default): Off, 1: On +vsync_enabled = + +# Turns on the frame limiter, which will limit frames output to the target game speed +# 0: Off, 1: On (default) +use_frame_limit = + +# Limits the speed of the game to run no faster than this value as a percentage of target speed +# 1 - 9999: Speed limit as a percentage of target game speed. 100 (default) +frame_limit = + +# The clear color for the renderer. What shows up on the sides of the bottom screen. +# Must be in range of 0.0-1.0. Defaults to 0.0 for all. +bg_red = +bg_blue = +bg_green = + +# Whether and how Stereoscopic 3D should be rendered +# 0 (default): Off, 1: Side by Side, 2: Anaglyph, 3: Interlaced, 4: Reverse Interlaced, 5: Cardboard VR +render_3d = + +# Change 3D Intensity +# 0 - 100: Intensity. 0 (default) +factor_3d = + +# The name of the post processing shader to apply. +# Loaded from shaders if render_3d is off or side by side. +# Loaded from shaders/anaglyph if render_3d is anaglyph +pp_shader_name = + +# Whether to enable linear filtering or not +# This is required for some shaders to work correctly +# 0: Nearest, 1 (default): Linear +filter_mode = + +[Layout] +# Layout for the screen inside the render window. +# 0 (default): Default Top Bottom Screen, 1: Single Screen Only, 2: Large Screen Small Screen, 3: Side by Side +layout_option = + +# Toggle custom layout (using the settings below) on or off. +# 0 (default): Off, 1: On +custom_layout = + +# Screen placement when using Custom layout option +# 0x, 0y is the top left corner of the render window. +custom_top_left = +custom_top_top = +custom_top_right = +custom_top_bottom = +custom_bottom_left = +custom_bottom_top = +custom_bottom_right = +custom_bottom_bottom = + +# Swaps the prominent screen with the other screen. +# For example, if Single Screen is chosen, setting this to 1 will display the bottom screen instead of the top screen. +# 0 (default): Top Screen is prominent, 1: Bottom Screen is prominent +swap_screen = + +# Screen placement settings when using Cardboard VR (render3d = 4) +# 30 - 100: Screen size as a percentage of the viewport. 85 (default) +cardboard_screen_size = +# -100 - 100: Screen X-Coordinate shift as a percentage of empty space. 0 (default) +cardboard_x_shift = +# -100 - 100: Screen Y-Coordinate shift as a percentage of empty space. 0 (default) +cardboard_y_shift = + +[Audio] +# Whether or not to enable DSP LLE +# 0 (default): No, 1: Yes +enable_dsp_lle = + +# Whether or not to run DSP LLE on a different thread +# 0 (default): No, 1: Yes +enable_dsp_lle_thread = + +# Which audio output engine to use. +# auto (default): Auto-select, null: No audio output, sdl2: SDL2 (if available) +output_engine = + +# Whether or not to enable the audio-stretching post-processing effect. +# This effect adjusts audio speed to match emulation speed and helps prevent audio stutter, +# at the cost of increasing audio latency. +# 0: No, 1 (default): Yes +enable_audio_stretching = + +# Which audio device to use. +# auto (default): Auto-select +output_device = + +# Which mic input type to use. +# 0: None, 1 (default): Real device, 2: Static noise +mic_input_type = + +# Output volume. +# 1.0 (default): 100%, 0.0; mute +volume = + +[Data Storage] +# Whether to create a virtual SD card. +# 1 (default): Yes, 0: No +use_virtual_sd = + +[System] +# The system model that Citra will try to emulate +# 0: Old 3DS (default), 1: New 3DS +is_new_3ds = + +# The system region that Citra will use during emulation +# -1: Auto-select (default), 0: Japan, 1: USA, 2: Europe, 3: Australia, 4: China, 5: Korea, 6: Taiwan +region_value = + +# The system language that Citra will use during emulation +# 0: Japanese, 1: English (default), 2: French, 3: German, 4: Italian, 5: Spanish, +# 6: Simplified Chinese, 7: Korean, 8: Dutch, 9: Portuguese, 10: Russian, 11: Traditional Chinese +language = + +# The clock to use when citra starts +# 0: System clock (default), 1: fixed time +init_clock = + +# Time used when init_clock is set to fixed_time in the format %Y-%m-%d %H:%M:%S +# set to fixed time. Default 2000-01-01 00:00:01 +# Note: 3DS can only handle times later then Jan 1 2000 +init_time = + +[Camera] +# Which camera engine to use for the right outer camera +# blank: a dummy camera that always returns black image +# image: loads a still image from the storage. When the camera is started, you will be prompted +# to select an image. +# ndk (Default): uses the device camera. You can specify the camera ID to use in the config field. +# If you don't specify an ID, the default setting will be used. For outer cameras, +# the back-facing camera will be used. For the inner camera, the front-facing +# camera will be used. Please note that 'Legacy' cameras are not supported. +camera_outer_right_name = + +# A config string for the right outer camera. Its meaning is defined by the camera engine +camera_outer_right_config = + +# The image flip to apply +# 0: None (default), 1: Horizontal, 2: Vertical, 3: Reverse +camera_outer_right_flip = + +# ... for the left outer camera +camera_outer_left_name = +camera_outer_left_config = +camera_outer_left_flip = + +# ... for the inner camera +camera_inner_name = +camera_inner_config = +camera_inner_flip = + +[Miscellaneous] +# A filter which removes logs below a certain logging level. +# Examples: *:Debug Kernel.SVC:Trace Service.*:Critical +log_filter = *:Info + +[Debugging] +# Record frame time data, can be found in the log directory. Boolean value +record_frame_times = +# Port for listening to GDB connections. +use_gdbstub=false +gdbstub_port=24689 +# To LLE a service module add "LLE\=true" + +[WebService] +# Whether or not to enable telemetry +# 0: No, 1 (default): Yes +enable_telemetry = +# URL for Web API +web_api_url = https://api.citra-emu.org +# Username and token for Citra Web Service +# See https://profile.citra-emu.org/ for more info +citra_username = +citra_token = +)"; +} diff --git a/src/android/app/src/main/jni/emu_window/emu_window.cpp b/src/android/app/src/main/jni/emu_window/emu_window.cpp new file mode 100644 index 000000000..86faef554 --- /dev/null +++ b/src/android/app/src/main/jni/emu_window/emu_window.cpp @@ -0,0 +1,275 @@ +// Copyright 2019 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#include +#include +#include +#include + +#include +#include + +#include "common/logging/log.h" +#include "core/settings.h" +#include "input_common/main.h" +#include "jni/emu_window/emu_window.h" +#include "jni/id_cache.h" +#include "jni/input_manager.h" +#include "network/network.h" +#include "video_core/renderer_base.h" +#include "video_core/video_core.h" + +static constexpr std::array egl_attribs{EGL_SURFACE_TYPE, + EGL_WINDOW_BIT, + EGL_RENDERABLE_TYPE, + EGL_OPENGL_ES3_BIT_KHR, + EGL_BLUE_SIZE, + 8, + EGL_GREEN_SIZE, + 8, + EGL_RED_SIZE, + 8, + EGL_DEPTH_SIZE, + 0, + EGL_STENCIL_SIZE, + 0, + EGL_NONE}; +static constexpr std::array egl_empty_attribs{EGL_WIDTH, 1, EGL_HEIGHT, 1, EGL_NONE}; +static constexpr std::array egl_context_attribs{EGL_CONTEXT_CLIENT_VERSION, 3, EGL_NONE}; + +SharedContext_Android::SharedContext_Android(EGLDisplay egl_display, EGLConfig egl_config, + EGLContext egl_share_context) + : egl_display{egl_display}, egl_surface{eglCreatePbufferSurface(egl_display, egl_config, + egl_empty_attribs.data())}, + egl_context{eglCreateContext(egl_display, egl_config, egl_share_context, + egl_context_attribs.data())} { + ASSERT_MSG(egl_surface, "eglCreatePbufferSurface() failed!"); + ASSERT_MSG(egl_context, "eglCreateContext() failed!"); +} + +SharedContext_Android::~SharedContext_Android() { + if (!eglDestroySurface(egl_display, egl_surface)) { + LOG_CRITICAL(Frontend, "eglDestroySurface() failed"); + } + + if (!eglDestroyContext(egl_display, egl_context)) { + LOG_CRITICAL(Frontend, "eglDestroySurface() failed"); + } +} + +void SharedContext_Android::MakeCurrent() { + eglMakeCurrent(egl_display, egl_surface, egl_surface, egl_context); +} + +void SharedContext_Android::DoneCurrent() { + eglMakeCurrent(egl_display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT); +} + +static bool IsPortraitMode() { + return JNI_FALSE != IDCache::GetEnvForThread()->CallStaticBooleanMethod( + IDCache::GetNativeLibraryClass(), IDCache::GetIsPortraitMode()); +} + +static void UpdateLandscapeScreenLayout() { + Settings::values.layout_option = + static_cast(IDCache::GetEnvForThread()->CallStaticIntMethod( + IDCache::GetNativeLibraryClass(), IDCache::GetLandscapeScreenLayout())); +} + +void EmuWindow_Android::OnSurfaceChanged(ANativeWindow* surface) { + render_window = surface; + StopPresenting(); +} + +bool EmuWindow_Android::OnTouchEvent(int x, int y, bool pressed) { + if (pressed) { + return TouchPressed((unsigned)std::max(x, 0), (unsigned)std::max(y, 0)); + } + + TouchReleased(); + return true; +} + +void EmuWindow_Android::OnTouchMoved(int x, int y) { + TouchMoved((unsigned)std::max(x, 0), (unsigned)std::max(y, 0)); +} + +void EmuWindow_Android::OnFramebufferSizeChanged() { + UpdateLandscapeScreenLayout(); + const bool is_portrait_mode{IsPortraitMode()}; + const int bigger{window_width > window_height ? window_width : window_height}; + const int smaller{window_width < window_height ? window_width : window_height}; + if (is_portrait_mode) { + UpdateCurrentFramebufferLayout(smaller, bigger, is_portrait_mode); + } else { + UpdateCurrentFramebufferLayout(bigger, smaller, is_portrait_mode); + } +} + +EmuWindow_Android::EmuWindow_Android(ANativeWindow* surface) { + LOG_DEBUG(Frontend, "Initializing EmuWindow_Android"); + + if (!surface) { + LOG_CRITICAL(Frontend, "surface is nullptr"); + return; + } + + Network::Init(); + + host_window = surface; + + if (egl_display = eglGetDisplay(EGL_DEFAULT_DISPLAY); egl_display == EGL_NO_DISPLAY) { + LOG_CRITICAL(Frontend, "eglGetDisplay() failed"); + return; + } + if (eglInitialize(egl_display, 0, 0) != EGL_TRUE) { + LOG_CRITICAL(Frontend, "eglInitialize() failed"); + return; + } + if (EGLint egl_num_configs{}; eglChooseConfig(egl_display, egl_attribs.data(), &egl_config, 1, + &egl_num_configs) != EGL_TRUE) { + LOG_CRITICAL(Frontend, "eglChooseConfig() failed"); + return; + } + + CreateWindowSurface(); + + if (eglQuerySurface(egl_display, egl_surface, EGL_WIDTH, &window_width) != EGL_TRUE) { + return; + } + if (eglQuerySurface(egl_display, egl_surface, EGL_HEIGHT, &window_height) != EGL_TRUE) { + return; + } + + if (egl_context = eglCreateContext(egl_display, egl_config, 0, egl_context_attribs.data()); + egl_context == EGL_NO_CONTEXT) { + LOG_CRITICAL(Frontend, "eglCreateContext() failed"); + return; + } + if (eglSurfaceAttrib(egl_display, egl_surface, EGL_SWAP_BEHAVIOR, EGL_BUFFER_DESTROYED) != + EGL_TRUE) { + LOG_CRITICAL(Frontend, "eglSurfaceAttrib() failed"); + return; + } + if (core_context = CreateSharedContext(); !core_context) { + LOG_CRITICAL(Frontend, "CreateSharedContext() failed"); + return; + } + if (eglMakeCurrent(egl_display, egl_surface, egl_surface, egl_context) != EGL_TRUE) { + LOG_CRITICAL(Frontend, "eglMakeCurrent() failed"); + return; + } + if (!gladLoadGLES2Loader((GLADloadproc)eglGetProcAddress)) { + LOG_CRITICAL(Frontend, "gladLoadGLES2Loader() failed"); + return; + } + if (!eglSwapInterval(egl_display, Settings::values.use_vsync_new ? 1 : 0)) { + LOG_CRITICAL(Frontend, "eglSwapInterval() failed"); + return; + } + + OnFramebufferSizeChanged(); +} + +bool EmuWindow_Android::CreateWindowSurface() { + if (!host_window) { + return true; + } + + EGLint format{}; + eglGetConfigAttrib(egl_display, egl_config, EGL_NATIVE_VISUAL_ID, &format); + ANativeWindow_setBuffersGeometry(host_window, 0, 0, format); + + if (egl_surface = eglCreateWindowSurface(egl_display, egl_config, host_window, 0); + egl_surface == EGL_NO_SURFACE) { + return {}; + } + + return !!egl_surface; +} + +void EmuWindow_Android::DestroyWindowSurface() { + if (!egl_surface) { + return; + } + if (eglGetCurrentSurface(EGL_DRAW) == egl_surface) { + eglMakeCurrent(egl_display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT); + } + if (!eglDestroySurface(egl_display, egl_surface)) { + LOG_CRITICAL(Frontend, "eglDestroySurface() failed"); + } + egl_surface = EGL_NO_SURFACE; +} + +void EmuWindow_Android::DestroyContext() { + if (!egl_context) { + return; + } + if (eglGetCurrentContext() == egl_context) { + eglMakeCurrent(egl_display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT); + } + if (!eglDestroyContext(egl_display, egl_context)) { + LOG_CRITICAL(Frontend, "eglDestroySurface() failed"); + } + if (!eglTerminate(egl_display)) { + LOG_CRITICAL(Frontend, "eglTerminate() failed"); + } + egl_context = EGL_NO_CONTEXT; + egl_display = EGL_NO_DISPLAY; +} + +EmuWindow_Android::~EmuWindow_Android() { + DestroyWindowSurface(); + DestroyContext(); +} + +std::unique_ptr EmuWindow_Android::CreateSharedContext() const { + return std::make_unique(egl_display, egl_config, egl_context); +} + +void EmuWindow_Android::StopPresenting() { + if (presenting_state == PresentingState::Running) { + eglMakeCurrent(egl_display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT); + } + presenting_state = PresentingState::Stopped; +} + +void EmuWindow_Android::TryPresenting() { + if (presenting_state != PresentingState::Running) { + if (presenting_state == PresentingState::Initial) { + eglMakeCurrent(egl_display, egl_surface, egl_surface, egl_context); + glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0); + presenting_state = PresentingState::Running; + } else { + return; + } + } + eglSwapInterval(egl_display, Settings::values.use_vsync_new ? 1 : 0); + if (VideoCore::g_renderer) { + VideoCore::g_renderer->TryPresent(0); + eglSwapBuffers(egl_display, egl_surface); + } +} + +void EmuWindow_Android::PollEvents() { + if (!render_window) { + return; + } + + host_window = render_window; + render_window = nullptr; + + DestroyWindowSurface(); + CreateWindowSurface(); + OnFramebufferSizeChanged(); + presenting_state = PresentingState::Initial; +} + +void EmuWindow_Android::MakeCurrent() { + core_context->MakeCurrent(); +} + +void EmuWindow_Android::DoneCurrent() { + core_context->DoneCurrent(); +} diff --git a/src/android/app/src/main/jni/emu_window/emu_window.h b/src/android/app/src/main/jni/emu_window/emu_window.h new file mode 100644 index 000000000..10a293c96 --- /dev/null +++ b/src/android/app/src/main/jni/emu_window/emu_window.h @@ -0,0 +1,83 @@ +// Copyright 2019 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#pragma once + +#include + +#include +#include + +#include "core/frontend/emu_window.h" + +struct ANativeWindow; + +class SharedContext_Android : public Frontend::GraphicsContext { +public: + SharedContext_Android(EGLDisplay egl_display, EGLConfig egl_config, + EGLContext egl_share_context); + + ~SharedContext_Android() override; + + void MakeCurrent() override; + + void DoneCurrent() override; + +private: + EGLDisplay egl_display{}; + EGLSurface egl_surface{}; + EGLContext egl_context{}; +}; + +class EmuWindow_Android : public Frontend::EmuWindow { +public: + EmuWindow_Android(ANativeWindow* surface); + ~EmuWindow_Android(); + + void Present(); + + /// Called by the onSurfaceChanges() method to change the surface + void OnSurfaceChanged(ANativeWindow* surface); + + /// Handles touch event that occur.(Touched or released) + bool OnTouchEvent(int x, int y, bool pressed); + + /// Handles movement of touch pointer + void OnTouchMoved(int x, int y); + + void PollEvents() override; + void MakeCurrent() override; + void DoneCurrent() override; + + void TryPresenting(); + void StopPresenting(); + + std::unique_ptr CreateSharedContext() const override; + +private: + void OnFramebufferSizeChanged(); + bool CreateWindowSurface(); + void DestroyWindowSurface(); + void DestroyContext(); + + ANativeWindow* render_window{}; + ANativeWindow* host_window{}; + + int window_width{}; + int window_height{}; + + EGLConfig egl_config; + EGLSurface egl_surface{}; + EGLContext egl_context{}; + EGLDisplay egl_display{}; + + std::unique_ptr core_context; + + enum class PresentingState { + Initial, + Running, + Stopped, + }; + PresentingState presenting_state{}; +}; diff --git a/src/android/app/src/main/jni/game_info.cpp b/src/android/app/src/main/jni/game_info.cpp new file mode 100644 index 000000000..80c0379d6 --- /dev/null +++ b/src/android/app/src/main/jni/game_info.cpp @@ -0,0 +1,150 @@ +// Copyright 2017 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#include +#include +#include +#include + +#include "common/string_util.h" +#include "core/hle/service/am/am.h" +#include "core/hle/service/fs/archive.h" +#include "core/loader/loader.h" +#include "core/loader/smdh.h" +#include "jni/game_info.h" + +namespace GameInfo { + +std::vector GetSMDHData(std::string physical_name) { + std::unique_ptr loader = Loader::GetLoader(physical_name); + if (!loader) { + return {}; + } + + u64 program_id = 0; + loader->ReadProgramId(program_id); + + std::vector smdh = [program_id, &loader]() -> std::vector { + std::vector original_smdh; + loader->ReadIcon(original_smdh); + + if (program_id < 0x00040000'00000000 || program_id > 0x00040000'FFFFFFFF) + return original_smdh; + + std::string update_path = Service::AM::GetTitleContentPath( + Service::FS::MediaType::SDMC, program_id + 0x0000000E'00000000); + + if (!FileUtil::Exists(update_path)) + return original_smdh; + + std::unique_ptr update_loader = Loader::GetLoader(update_path); + + if (!update_loader) + return original_smdh; + + std::vector update_smdh; + update_loader->ReadIcon(update_smdh); + return update_smdh; + }(); + + return smdh; +} + +std::u16string GetTitle(std::string physical_name) { + Loader::SMDH::TitleLanguage language = Loader::SMDH::TitleLanguage::English; + std::vector smdh_data = GetSMDHData(physical_name); + + if (!Loader::IsValidSMDH(smdh_data)) { + // SMDH is not valid, return null + return {}; + } + + Loader::SMDH smdh; + memcpy(&smdh, smdh_data.data(), sizeof(Loader::SMDH)); + + // Get the title from SMDH in UTF-16 format + std::u16string title{ + reinterpret_cast(smdh.titles[static_cast(language)].long_title.data())}; + + return title; +} + +std::u16string GetPublisher(std::string physical_name) { + Loader::SMDH::TitleLanguage language = Loader::SMDH::TitleLanguage::English; + std::vector smdh_data = GetSMDHData(physical_name); + + if (!Loader::IsValidSMDH(smdh_data)) { + // SMDH is not valid, return null + return {}; + } + + Loader::SMDH smdh; + memcpy(&smdh, smdh_data.data(), sizeof(Loader::SMDH)); + + // Get the Publisher's name from SMDH in UTF-16 format + char16_t* publisher; + publisher = + reinterpret_cast(smdh.titles[static_cast(language)].publisher.data()); + + return publisher; +} + +std::string GetRegions(std::string physical_name) { + std::vector smdh_data = GetSMDHData(physical_name); + + if (!Loader::IsValidSMDH(smdh_data)) { + // SMDH is not valid, return "Invalid region" + return "Invalid region"; + } + + Loader::SMDH smdh; + memcpy(&smdh, smdh_data.data(), sizeof(Loader::SMDH)); + + using GameRegion = Loader::SMDH::GameRegion; + static const std::map regions_map = { + {GameRegion::Japan, "Japan"}, {GameRegion::NorthAmerica, "North America"}, + {GameRegion::Europe, "Europe"}, {GameRegion::Australia, "Australia"}, + {GameRegion::China, "China"}, {GameRegion::Korea, "Korea"}, + {GameRegion::Taiwan, "Taiwan"}}; + std::vector regions = smdh.GetRegions(); + + if (regions.empty()) { + return "Invalid region"; + } + + const bool region_free = + std::all_of(regions_map.begin(), regions_map.end(), [®ions](const auto& it) { + return std::find(regions.begin(), regions.end(), it.first) != regions.end(); + }); + + if (region_free) { + return "Region free"; + } + + const std::string separator = ", "; + std::string result = regions_map.at(regions.front()); + for (auto region = ++regions.begin(); region != regions.end(); ++region) { + result += separator + regions_map.at(*region); + } + + return result; +} + +std::vector GetIcon(std::string physical_name) { + std::vector smdh_data = GetSMDHData(physical_name); + + if (!Loader::IsValidSMDH(smdh_data)) { + // SMDH is not valid, return null + return std::vector(0, 0); + } + + Loader::SMDH smdh; + memcpy(&smdh, smdh_data.data(), sizeof(Loader::SMDH)); + + // Always get a 48x48(large) icon + std::vector icon_data = smdh.GetIcon(true); + return icon_data; +} + +} // namespace GameInfo diff --git a/src/android/app/src/main/jni/game_info.h b/src/android/app/src/main/jni/game_info.h new file mode 100644 index 000000000..ad3254aab --- /dev/null +++ b/src/android/app/src/main/jni/game_info.h @@ -0,0 +1,20 @@ +// Copyright 2017 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#include +#include + +#include "common/common_types.h" + +namespace GameInfo { +std::vector GetSMDHData(std::string physical_name); + +std::u16string GetTitle(std::string physical_name); + +std::u16string GetPublisher(std::string physical_name); + +std::string GetRegions(std::string physical_name); + +std::vector GetIcon(std::string physical_name); +} // namespace GameInfo diff --git a/src/android/app/src/main/jni/game_settings.cpp b/src/android/app/src/main/jni/game_settings.cpp new file mode 100644 index 000000000..3feb42704 --- /dev/null +++ b/src/android/app/src/main/jni/game_settings.cpp @@ -0,0 +1,45 @@ +// Copyright 2019 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#include "core/settings.h" + +namespace GameSettings { + +void LoadOverrides(u64 program_id) { + switch (program_id) { + // JAP / The Legend of Zelda: Ocarina of Time 3D + case 0x0004000000033400: + // USA / The Legend of Zelda: Ocarina of Time 3D + case 0x0004000000033500: + // EUR / The Legend of Zelda: Ocarina of Time 3D + case 0x0004000000033600: + // KOR / The Legend of Zelda: Ocarina of Time 3D + case 0x000400000008F800: + // CHI / The Legend of Zelda: Ocarina of Time 3D + case 0x000400000008F900: + // This game requires accurate multiplication to render properly + Settings::values.shaders_accurate_mul = true; + break; + + // USA / Mario & Luigi: Superstar Saga + Bowsers Minions + case 0x00040000001B8F00: + // EUR / Mario & Luigi: Superstar Saga + Bowsers Minions + case 0x00040000001B9000: + // JAP / Mario & Luigi: Superstar Saga + Bowsers Minions + case 0x0004000000194B00: + // This game requires accurate multiplication to render properly + Settings::values.shaders_accurate_mul = true; + break; + + // USA / Mario & Luigi: Bowsers Inside Story + Bowser Jrs Journey + case 0x00040000001D1400: + // EUR / Mario & Luigi: Bowsers Inside Story + Bowser Jrs Journey + case 0x00040000001D1500: + // This game requires accurate multiplication to render properly + Settings::values.shaders_accurate_mul = true; + break; + } +} + +} // namespace GameSettings diff --git a/src/android/app/src/main/jni/game_settings.h b/src/android/app/src/main/jni/game_settings.h new file mode 100644 index 000000000..b034e1865 --- /dev/null +++ b/src/android/app/src/main/jni/game_settings.h @@ -0,0 +1,11 @@ +// Copyright 2020 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#include "common/common_types.h" + +namespace GameSettings { + +void LoadOverrides(u64 program_id); + +} // namespace GameSettings diff --git a/src/android/app/src/main/jni/id_cache.cpp b/src/android/app/src/main/jni/id_cache.cpp new file mode 100644 index 000000000..cf1c24437 --- /dev/null +++ b/src/android/app/src/main/jni/id_cache.cpp @@ -0,0 +1,235 @@ +// Copyright 2019 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#include "common/common_paths.h" +#include "common/logging/backend.h" +#include "common/logging/filter.h" +#include "common/logging/log.h" +#include "core/settings.h" +#include "jni/applets/mii_selector.h" +#include "jni/applets/swkbd.h" +#include "jni/camera/still_image_camera.h" +#include "jni/id_cache.h" + +#include + +static constexpr jint JNI_VERSION = JNI_VERSION_1_6; + +static JavaVM* s_java_vm; + +static jclass s_native_library_class; +static jclass s_core_error_class; +static jclass s_savestate_info_class; +static jclass s_disk_cache_progress_class; +static jclass s_load_callback_stage_class; +static jmethodID s_on_core_error; +static jmethodID s_display_alert_msg; +static jmethodID s_display_alert_prompt; +static jmethodID s_alert_prompt_button; +static jmethodID s_is_portrait_mode; +static jmethodID s_landscape_screen_layout; +static jmethodID s_exit_emulation_activity; +static jmethodID s_request_camera_permission; +static jmethodID s_request_mic_permission; +static jmethodID s_disk_cache_load_progress; + +static std::unordered_map s_java_load_callback_stages; + +namespace IDCache { + +JNIEnv* GetEnvForThread() { + thread_local static struct OwnedEnv { + OwnedEnv() { + status = s_java_vm->GetEnv(reinterpret_cast(&env), JNI_VERSION_1_6); + if (status == JNI_EDETACHED) + s_java_vm->AttachCurrentThread(&env, nullptr); + } + + ~OwnedEnv() { + if (status == JNI_EDETACHED) + s_java_vm->DetachCurrentThread(); + } + + int status; + JNIEnv* env = nullptr; + } owned; + return owned.env; +} + +jclass GetNativeLibraryClass() { + return s_native_library_class; +} + +jclass GetCoreErrorClass() { + return s_core_error_class; +} + +jclass GetSavestateInfoClass() { + return s_savestate_info_class; +} + +jclass GetDiskCacheProgressClass() { + return s_disk_cache_progress_class; +} + +jclass GetDiskCacheLoadCallbackStageClass() { + return s_load_callback_stage_class; +} + +jmethodID GetOnCoreError() { + return s_on_core_error; +} + +jmethodID GetDisplayAlertMsg() { + return s_display_alert_msg; +} + +jmethodID GetDisplayAlertPrompt() { + return s_display_alert_prompt; +} + +jmethodID GetAlertPromptButton() { + return s_alert_prompt_button; +} + +jmethodID GetIsPortraitMode() { + return s_is_portrait_mode; +} + +jmethodID GetLandscapeScreenLayout() { + return s_landscape_screen_layout; +} + +jmethodID GetExitEmulationActivity() { + return s_exit_emulation_activity; +} + +jmethodID GetRequestCameraPermission() { + return s_request_camera_permission; +} + +jmethodID GetRequestMicPermission() { + return s_request_mic_permission; +} + +jmethodID GetDiskCacheLoadProgress() { + return s_disk_cache_load_progress; +} + +jobject GetJavaLoadCallbackStage(VideoCore::LoadCallbackStage stage) { + const auto it = s_java_load_callback_stages.find(stage); + ASSERT_MSG(it != s_java_load_callback_stages.end(), "Invalid LoadCallbackStage: {}", stage); + + return it->second; +} + +} // namespace IDCache + +#ifdef __cplusplus +extern "C" { +#endif + +jint JNI_OnLoad(JavaVM* vm, void* reserved) { + s_java_vm = vm; + + JNIEnv* env; + if (vm->GetEnv(reinterpret_cast(&env), JNI_VERSION) != JNI_OK) + return JNI_ERR; + + // Initialize Logger + Log::Filter log_filter; + log_filter.ParseFilterString(Settings::values.log_filter); + Log::SetGlobalFilter(log_filter); + Log::AddBackend(std::make_unique()); + FileUtil::CreateFullPath(FileUtil::GetUserPath(FileUtil::UserPath::LogDir)); + Log::AddBackend(std::make_unique( + FileUtil::GetUserPath(FileUtil::UserPath::LogDir) + LOG_FILE)); + LOG_INFO(Frontend, "Logging backend initialised"); + + // Initialize Java classes + const jclass native_library_class = env->FindClass("org/citra/citra_emu/NativeLibrary"); + s_native_library_class = reinterpret_cast(env->NewGlobalRef(native_library_class)); + s_savestate_info_class = reinterpret_cast( + env->NewGlobalRef(env->FindClass("org/citra/citra_emu/NativeLibrary$SavestateInfo"))); + s_core_error_class = reinterpret_cast( + env->NewGlobalRef(env->FindClass("org/citra/citra_emu/NativeLibrary$CoreError"))); + s_disk_cache_progress_class = reinterpret_cast(env->NewGlobalRef( + env->FindClass("org/citra/citra_emu/disk_shader_cache/DiskShaderCacheProgress"))); + s_load_callback_stage_class = reinterpret_cast(env->NewGlobalRef(env->FindClass( + "org/citra/citra_emu/disk_shader_cache/DiskShaderCacheProgress$LoadCallbackStage"))); + + // Initialize Java methods + s_on_core_error = env->GetStaticMethodID( + s_native_library_class, "OnCoreError", + "(Lorg/citra/citra_emu/NativeLibrary$CoreError;Ljava/lang/String;)Z"); + s_display_alert_msg = env->GetStaticMethodID(s_native_library_class, "displayAlertMsg", + "(Ljava/lang/String;Ljava/lang/String;Z)Z"); + s_display_alert_prompt = + env->GetStaticMethodID(s_native_library_class, "displayAlertPrompt", + "(Ljava/lang/String;Ljava/lang/String;I)Ljava/lang/String;"); + s_alert_prompt_button = + env->GetStaticMethodID(s_native_library_class, "alertPromptButton", "()I"); + s_is_portrait_mode = env->GetStaticMethodID(s_native_library_class, "isPortraitMode", "()Z"); + s_landscape_screen_layout = + env->GetStaticMethodID(s_native_library_class, "landscapeScreenLayout", "()I"); + s_exit_emulation_activity = + env->GetStaticMethodID(s_native_library_class, "exitEmulationActivity", "(I)V"); + s_request_camera_permission = + env->GetStaticMethodID(s_native_library_class, "RequestCameraPermission", "()Z"); + s_request_mic_permission = + env->GetStaticMethodID(s_native_library_class, "RequestMicPermission", "()Z"); + s_disk_cache_load_progress = env->GetStaticMethodID( + s_disk_cache_progress_class, "loadProgress", + "(Lorg/citra/citra_emu/disk_shader_cache/DiskShaderCacheProgress$LoadCallbackStage;II)V"); + + // Initialize LoadCallbackStage map + const auto to_java_load_callback_stage = [env](const std::string& stage) { + jclass load_callback_stage_class = IDCache::GetDiskCacheLoadCallbackStageClass(); + return env->NewGlobalRef(env->GetStaticObjectField( + load_callback_stage_class, + env->GetStaticFieldID(load_callback_stage_class, stage.c_str(), + "Lorg/citra/citra_emu/disk_shader_cache/" + "DiskShaderCacheProgress$LoadCallbackStage;"))); + }; + + s_java_load_callback_stages.emplace(VideoCore::LoadCallbackStage::Prepare, + to_java_load_callback_stage("Prepare")); + s_java_load_callback_stages.emplace(VideoCore::LoadCallbackStage::Decompile, + to_java_load_callback_stage("Decompile")); + s_java_load_callback_stages.emplace(VideoCore::LoadCallbackStage::Build, + to_java_load_callback_stage("Build")); + s_java_load_callback_stages.emplace(VideoCore::LoadCallbackStage::Complete, + to_java_load_callback_stage("Complete")); + + MiiSelector::InitJNI(env); + SoftwareKeyboard::InitJNI(env); + Camera::StillImage::InitJNI(env); + + return JNI_VERSION; +} + +void JNI_OnUnload(JavaVM* vm, void* reserved) { + JNIEnv* env; + if (vm->GetEnv(reinterpret_cast(&env), JNI_VERSION) != JNI_OK) { + return; + } + + env->DeleteGlobalRef(s_native_library_class); + env->DeleteGlobalRef(s_savestate_info_class); + env->DeleteGlobalRef(s_core_error_class); + env->DeleteGlobalRef(s_disk_cache_progress_class); + env->DeleteGlobalRef(s_load_callback_stage_class); + + for (auto& [key, object] : s_java_load_callback_stages) { + env->DeleteGlobalRef(object); + } + + MiiSelector::CleanupJNI(env); + SoftwareKeyboard::CleanupJNI(env); + Camera::StillImage::CleanupJNI(env); +} + +#ifdef __cplusplus +} +#endif diff --git a/src/android/app/src/main/jni/id_cache.h b/src/android/app/src/main/jni/id_cache.h new file mode 100644 index 000000000..4b8c89511 --- /dev/null +++ b/src/android/app/src/main/jni/id_cache.h @@ -0,0 +1,50 @@ +// Copyright 2019 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#pragma once + +#include +#include +#include +#include "video_core/rasterizer_interface.h" + +namespace IDCache { + +JNIEnv* GetEnvForThread(); +jclass GetNativeLibraryClass(); +jclass GetCoreErrorClass(); +jclass GetSavestateInfoClass(); +jclass GetDiskCacheProgressClass(); +jclass GetDiskCacheLoadCallbackStageClass(); +jmethodID GetOnCoreError(); +jmethodID GetDisplayAlertMsg(); +jmethodID GetDisplayAlertPrompt(); +jmethodID GetAlertPromptButton(); +jmethodID GetIsPortraitMode(); +jmethodID GetLandscapeScreenLayout(); +jmethodID GetExitEmulationActivity(); +jmethodID GetRequestCameraPermission(); +jmethodID GetRequestMicPermission(); +jmethodID GetDiskCacheLoadProgress(); + +jobject GetJavaLoadCallbackStage(VideoCore::LoadCallbackStage stage); + +} // namespace IDCache + +template +using SharedGlobalRef = std::shared_ptr>; + +struct SharedGlobalRefDeleter { + void operator()(jobject ptr) { + JNIEnv* env = IDCache::GetEnvForThread(); + env->DeleteGlobalRef(ptr); + } +}; + +template +SharedGlobalRef NewSharedGlobalRef(T object) { + JNIEnv* env = IDCache::GetEnvForThread(); + auto* global_ref = reinterpret_cast(env->NewGlobalRef(object)); + return SharedGlobalRef(global_ref, SharedGlobalRefDeleter()); +} diff --git a/src/android/app/src/main/jni/input_manager.cpp b/src/android/app/src/main/jni/input_manager.cpp new file mode 100644 index 000000000..9c58e3527 --- /dev/null +++ b/src/android/app/src/main/jni/input_manager.cpp @@ -0,0 +1,332 @@ +// Copyright 2017 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#include +#include +#include +#include +#include +#include +#include +#include "common/logging/log.h" +#include "common/math_util.h" +#include "common/param_package.h" +#include "input_common/main.h" +#include "input_common/sdl/sdl.h" +#include "jni/input_manager.h" +#include "jni/ndk_motion.h" + +namespace InputManager { + +static std::shared_ptr button; +static std::shared_ptr analog; +static std::shared_ptr motion; + +// Button Handler +class KeyButton final : public Input::ButtonDevice { +public: + explicit KeyButton(std::shared_ptr button_list_) : button_list(button_list_) {} + + ~KeyButton(); + + bool GetStatus() const override { + return status.load(); + } + + friend class ButtonList; + +private: + std::shared_ptr button_list; + std::atomic status{false}; +}; + +struct KeyButtonPair { + int button_id; + KeyButton* key_button; +}; + +class ButtonList { +public: + void AddButton(int button_id, KeyButton* key_button) { + std::lock_guard guard(mutex); + list.push_back(KeyButtonPair{button_id, key_button}); + } + + void RemoveButton(const KeyButton* key_button) { + std::lock_guard guard(mutex); + list.remove_if( + [key_button](const KeyButtonPair& pair) { return pair.key_button == key_button; }); + } + + bool ChangeButtonStatus(int button_id, bool pressed) { + std::lock_guard guard(mutex); + bool button_found = false; + for (const KeyButtonPair& pair : list) { + if (pair.button_id == button_id) { + pair.key_button->status.store(pressed); + button_found = true; + } + } + // If we don't find the button don't consume the button press event + return button_found; + } + + void ChangeAllButtonStatus(bool pressed) { + std::lock_guard guard(mutex); + for (const KeyButtonPair& pair : list) { + pair.key_button->status.store(pressed); + } + } + +private: + std::mutex mutex; + std::list list; +}; + +KeyButton::~KeyButton() { + button_list->RemoveButton(this); +} + +// Analog Button +class AnalogButton final : public Input::ButtonDevice { +public: + explicit AnalogButton(std::shared_ptr button_list_, float threshold_, + bool trigger_if_greater_) + : button_list(button_list_), threshold(threshold_), + trigger_if_greater(trigger_if_greater_) {} + + ~AnalogButton(); + + bool GetStatus() const override { + if (trigger_if_greater) + return axis_val.load() > threshold; + return axis_val.load() < threshold; + } + + friend class AnalogButtonList; + +private: + std::shared_ptr button_list; + std::atomic axis_val{0.0f}; + float threshold; + bool trigger_if_greater; +}; + +struct AnalogButtonPair { + int axis_id; + AnalogButton* key_button; +}; + +class AnalogButtonList { +public: + void AddAnalogButton(int button_id, AnalogButton* key_button) { + std::lock_guard guard(mutex); + list.push_back(AnalogButtonPair{button_id, key_button}); + } + + void RemoveButton(const AnalogButton* key_button) { + std::lock_guard guard(mutex); + list.remove_if( + [key_button](const AnalogButtonPair& pair) { return pair.key_button == key_button; }); + } + + bool ChangeAxisValue(int axis_id, float axis) { + std::lock_guard guard(mutex); + bool button_found = false; + for (const AnalogButtonPair& pair : list) { + if (pair.axis_id == axis_id) { + pair.key_button->axis_val.store(axis); + button_found = true; + } + } + // If we don't find the button don't consume the button press event + return button_found; + } + +private: + std::mutex mutex; + std::list list; +}; + +AnalogButton::~AnalogButton() { + button_list->RemoveButton(this); +} + +// Joystick Handler +class Joystick final : public Input::AnalogDevice { +public: + explicit Joystick(std::shared_ptr analog_list_) : analog_list(analog_list_) {} + + ~Joystick(); + + std::tuple GetStatus() const override { + return std::make_tuple(x_axis.load(), y_axis.load()); + } + + friend class AnalogList; + +private: + std::shared_ptr analog_list; + std::atomic x_axis{0.0f}; + std::atomic y_axis{0.0f}; +}; + +struct AnalogPair { + int analog_id; + Joystick* key_button; +}; + +class AnalogList { +public: + void AddButton(int analog_id, Joystick* key_button) { + std::lock_guard guard(mutex); + list.push_back(AnalogPair{analog_id, key_button}); + } + + void RemoveButton(const Joystick* key_button) { + std::lock_guard guard(mutex); + list.remove_if( + [key_button](const AnalogPair& pair) { return pair.key_button == key_button; }); + } + + bool ChangeJoystickStatus(int analog_id, float x, float y) { + std::lock_guard guard(mutex); + bool button_found = false; + for (const AnalogPair& pair : list) { + if (pair.analog_id == analog_id) { + pair.key_button->x_axis.store(x); + pair.key_button->y_axis.store(y); + button_found = true; + } + } + return button_found; + } + +private: + std::mutex mutex; + std::list list; +}; + +AnalogFactory::AnalogFactory() : analog_list{std::make_shared()} {} + +Joystick::~Joystick() { + analog_list->RemoveButton(this); +} + +ButtonFactory::ButtonFactory() + : button_list{std::make_shared()}, analog_button_list{ + std::make_shared()} {} + +std::unique_ptr ButtonFactory::Create(const Common::ParamPackage& params) { + if (params.Has("axis")) { + const int axis_id = params.Get("axis", 0); + const float threshold = params.Get("threshold", 0.5f); + const std::string direction_name = params.Get("direction", ""); + bool trigger_if_greater; + if (direction_name == "+") { + trigger_if_greater = true; + } else if (direction_name == "-") { + trigger_if_greater = false; + } else { + trigger_if_greater = true; + LOG_ERROR(Input, "Unknown direction {}", direction_name); + } + std::unique_ptr analog_button = + std::make_unique(analog_button_list, threshold, trigger_if_greater); + analog_button_list->AddAnalogButton(axis_id, analog_button.get()); + return std::move(analog_button); + } + + int button_id = params.Get("code", 0); + std::unique_ptr key_button = std::make_unique(button_list); + button_list->AddButton(button_id, key_button.get()); + return std::move(key_button); +} + +bool ButtonFactory::PressKey(int button_id) { + return button_list->ChangeButtonStatus(button_id, true); +} + +bool ButtonFactory::ReleaseKey(int button_id) { + return button_list->ChangeButtonStatus(button_id, false); +} + +bool ButtonFactory::AnalogButtonEvent(int axis_id, float axis_val) { + return analog_button_list->ChangeAxisValue(axis_id, axis_val); +} + +std::unique_ptr AnalogFactory::Create(const Common::ParamPackage& params) { + int analog_id = params.Get("code", 0); + std::unique_ptr analog = std::make_unique(analog_list); + analog_list->AddButton(analog_id, analog.get()); + return std::move(analog); +} + +bool AnalogFactory::MoveJoystick(int analog_id, float x, float y) { + return analog_list->ChangeJoystickStatus(analog_id, x, y); +} + +ButtonFactory* ButtonHandler() { + return button.get(); +} + +AnalogFactory* AnalogHandler() { + return analog.get(); +} + +std::string GenerateButtonParamPackage(int button) { + Common::ParamPackage param{ + {"engine", "gamepad"}, + {"code", std::to_string(button)}, + }; + return param.Serialize(); +} + +std::string GenerateAnalogButtonParamPackage(int axis, float axis_val) { + Common::ParamPackage param{ + {"engine", "gamepad"}, + {"axis", std::to_string(axis)}, + }; + if (axis_val > 0) { + param.Set("direction", "+"); + param.Set("threshold", "0.5"); + } else { + param.Set("direction", "-"); + param.Set("threshold", "-0.5"); + } + + return param.Serialize(); +} + +std::string GenerateAnalogParamPackage(int axis_id) { + Common::ParamPackage param{ + {"engine", "gamepad"}, + {"code", std::to_string(axis_id)}, + }; + return param.Serialize(); +} + +NDKMotionFactory* NDKMotionHandler() { + return motion.get(); +} + +void Init() { + button = std::make_shared(); + analog = std::make_shared(); + motion = std::make_shared(); + Input::RegisterFactory("gamepad", button); + Input::RegisterFactory("gamepad", analog); + Input::RegisterFactory("motion_emu", motion); +} + +void Shutdown() { + Input::UnregisterFactory("gamepad"); + Input::UnregisterFactory("gamepad"); + Input::UnregisterFactory("motion_emu"); + button.reset(); + analog.reset(); + motion.reset(); +} + +} // namespace InputManager diff --git a/src/android/app/src/main/jni/input_manager.h b/src/android/app/src/main/jni/input_manager.h new file mode 100644 index 000000000..637eeb67b --- /dev/null +++ b/src/android/app/src/main/jni/input_manager.h @@ -0,0 +1,141 @@ +// Copyright 2013 Dolphin Emulator Project / 2017 Citra Emulator Project +// Licensed under GPLv2+ +// Refer to the license.txt file included. + +#pragma once + +#include +#include +#include +#include "core/frontend/input.h" +#include "jni/ndk_motion.h" + +namespace InputManager { + +enum ButtonType { + // 3DS Controls + N3DS_BUTTON_A = 700, + N3DS_BUTTON_B = 701, + N3DS_BUTTON_X = 702, + N3DS_BUTTON_Y = 703, + N3DS_BUTTON_START = 704, + N3DS_BUTTON_SELECT = 705, + N3DS_BUTTON_HOME = 706, + N3DS_BUTTON_ZL = 707, + N3DS_BUTTON_ZR = 708, + N3DS_DPAD_UP = 709, + N3DS_DPAD_DOWN = 710, + N3DS_DPAD_LEFT = 711, + N3DS_DPAD_RIGHT = 712, + N3DS_CIRCLEPAD = 713, + N3DS_CIRCLEPAD_UP = 714, + N3DS_CIRCLEPAD_DOWN = 715, + N3DS_CIRCLEPAD_LEFT = 716, + N3DS_CIRCLEPAD_RIGHT = 717, + N3DS_STICK_C = 718, + N3DS_STICK_C_UP = 719, + N3DS_STICK_C_DOWN = 720, + N3DS_STICK_C_LEFT = 771, + N3DS_STICK_C_RIGHT = 772, + N3DS_TRIGGER_L = 773, + N3DS_TRIGGER_R = 774, + N3DS_BUTTON_DEBUG = 781, + N3DS_BUTTON_GPIO14 = 782 +}; + +class ButtonList; +class AnalogButtonList; +class AnalogList; + +/** + * A button device factory representing a gamepad. It receives input events and forward them + * to all button devices it created. + */ +class ButtonFactory final : public Input::Factory { +public: + ButtonFactory(); + + /** + * Creates a button device from a gamepad button + * @param params contains parameters for creating the device: + * - "code": the code of the key to bind with the button + */ + std::unique_ptr Create(const Common::ParamPackage& params) override; + + /** + * Sets the status of all buttons bound with the key to pressed + * @param key_code the code of the key to press + * @return whether the key event is consumed or not + */ + bool PressKey(int button_id); + + /** + * Sets the status of all buttons bound with the key to released + * @param key_code the code of the key to release + * @return whether the key event is consumed or not + */ + bool ReleaseKey(int button_id); + + /** + * Sets the status of all buttons bound with the key to released + * @param axis_id the code of the axis + * @param axis_val the value of the axis + * @return whether the key event is consumed or not + */ + bool AnalogButtonEvent(int axis_id, float axis_val); + + void ReleaseAllKeys(); + +private: + std::shared_ptr button_list; + std::shared_ptr analog_button_list; +}; + +/** + * An analog device factory representing a gamepad(virtual or physical). It receives input events + * and forward them to all analog devices it created. + */ +class AnalogFactory final : public Input::Factory { +public: + AnalogFactory(); + + /** + * Creates an analog device from the gamepad joystick + * @param params contains parameters for creating the device: + * - "code": the code of the key to bind with the button + */ + std::unique_ptr Create(const Common::ParamPackage& params) override; + + /** + * Sets the status of all buttons bound with the key to pressed + * @param key_code the code of the analog stick + * @param x the x-axis value of the analog stick + * @param y the y-axis value of the analog stick + */ + bool MoveJoystick(int analog_id, float x, float y); + +private: + std::shared_ptr analog_list; +}; + +/// Initializes and registers all built-in input device factories. +void Init(); + +/// Deregisters all built-in input device factories and shuts them down. +void Shutdown(); + +/// Gets the gamepad button device factory. +ButtonFactory* ButtonHandler(); + +/// Gets the gamepad analog device factory. +AnalogFactory* AnalogHandler(); + +/// Gets the NDK Motion device factory. +NDKMotionFactory* NDKMotionHandler(); + +std::string GenerateButtonParamPackage(int type); + +std::string GenerateAnalogButtonParamPackage(int axis, float axis_val); + +std::string GenerateAnalogParamPackage(int type); +} // namespace InputManager diff --git a/src/android/app/src/main/jni/mic.cpp b/src/android/app/src/main/jni/mic.cpp new file mode 100644 index 000000000..90191fbcf --- /dev/null +++ b/src/android/app/src/main/jni/mic.cpp @@ -0,0 +1,38 @@ +// Copyright 2020 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#include +#include "common/logging/log.h" +#include "jni/id_cache.h" +#include "jni/mic.h" + +#ifdef HAVE_CUBEB +#include "audio_core/cubeb_input.h" +#endif + +namespace Mic { + +AndroidFactory::~AndroidFactory() = default; + +std::unique_ptr AndroidFactory::Create(std::string mic_device_name) { +#ifdef HAVE_CUBEB + if (!permission_granted) { + JNIEnv* env = IDCache::GetEnvForThread(); + permission_granted = env->CallStaticBooleanMethod(IDCache::GetNativeLibraryClass(), + IDCache::GetRequestMicPermission()); + } + + if (permission_granted) { + return std::make_unique(std::move(mic_device_name)); + } else { + LOG_WARNING(Frontend, "Mic permissions denied"); + return std::make_unique(); + } +#else + LOG_WARNING(Frontend, "No cubeb support"); + return std::make_unique(); +#endif +} + +} // namespace Mic diff --git a/src/android/app/src/main/jni/mic.h b/src/android/app/src/main/jni/mic.h new file mode 100644 index 000000000..d790d52e5 --- /dev/null +++ b/src/android/app/src/main/jni/mic.h @@ -0,0 +1,21 @@ +// Copyright 2020 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#pragma once + +#include "core/frontend/mic.h" + +namespace Mic { + +class AndroidFactory final : public Frontend::Mic::RealMicFactory { +public: + ~AndroidFactory() override; + + std::unique_ptr Create(std::string mic_device_name) override; + +private: + bool permission_granted = false; +}; + +} // namespace Mic diff --git a/src/android/app/src/main/jni/native.cpp b/src/android/app/src/main/jni/native.cpp new file mode 100644 index 000000000..6dffc1225 --- /dev/null +++ b/src/android/app/src/main/jni/native.cpp @@ -0,0 +1,730 @@ +// Copyright 2019 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#include +#include +#include +#include + +#include +#include + +#include "audio_core/dsp_interface.h" +#include "common/aarch64/cpu_detect.h" +#include "common/file_util.h" +#include "common/logging/log.h" +#include "common/microprofile.h" +#include "common/scm_rev.h" +#include "common/scope_exit.h" +#include "common/string_util.h" +#include "core/core.h" +#include "core/frontend/applets/default_applets.h" +#include "core/frontend/camera/factory.h" +#include "core/frontend/mic.h" +#include "core/frontend/scope_acquire_context.h" +#include "core/hle/service/am/am.h" +#include "core/hle/service/nfc/nfc.h" +#include "core/savestate.h" +#include "core/settings.h" +#include "jni/applets/mii_selector.h" +#include "jni/applets/swkbd.h" +#include "jni/camera/ndk_camera.h" +#include "jni/camera/still_image_camera.h" +#include "jni/config.h" +#include "jni/emu_window/emu_window.h" +#include "jni/game_info.h" +#include "jni/game_settings.h" +#include "jni/id_cache.h" +#include "jni/input_manager.h" +#include "jni/mic.h" +#include "jni/native.h" +#include "jni/ndk_motion.h" +#include "video_core/renderer_base.h" +#include "video_core/renderer_opengl/texture_filters/texture_filterer.h" + +namespace { + +ANativeWindow* s_surf; + +std::unique_ptr window; + +std::atomic stop_run{true}; +std::atomic pause_emulation{false}; + +std::mutex paused_mutex; +std::mutex running_mutex; +std::condition_variable running_cv; + +} // Anonymous namespace + +static std::string GetJString(JNIEnv* env, jstring jstr) { + if (!jstr) { + return {}; + } + + const char* s = env->GetStringUTFChars(jstr, nullptr); + std::string result = s; + env->ReleaseStringUTFChars(jstr, s); + return result; +} + +static bool DisplayAlertMessage(const char* caption, const char* text, bool yes_no) { + JNIEnv* env = IDCache::GetEnvForThread(); + + // Execute the Java method. + jboolean result = env->CallStaticBooleanMethod( + IDCache::GetNativeLibraryClass(), IDCache::GetDisplayAlertMsg(), env->NewStringUTF(caption), + env->NewStringUTF(text), yes_no ? JNI_TRUE : JNI_FALSE); + + return result != JNI_FALSE; +} + +static std::string DisplayAlertPrompt(const char* caption, const char* text, int buttonConfig) { + JNIEnv* env = IDCache::GetEnvForThread(); + + jstring value = reinterpret_cast(env->CallStaticObjectMethod( + IDCache::GetNativeLibraryClass(), IDCache::GetDisplayAlertPrompt(), + env->NewStringUTF(caption), env->NewStringUTF(text), buttonConfig)); + + return GetJString(env, value); +} + +static int AlertPromptButton() { + JNIEnv* env = IDCache::GetEnvForThread(); + + // Execute the Java method. + return static_cast(env->CallStaticIntMethod(IDCache::GetNativeLibraryClass(), + IDCache::GetAlertPromptButton())); +} + +static jobject ToJavaCoreError(Core::System::ResultStatus result) { + static const std::map CoreErrorNameMap{ + {Core::System::ResultStatus::ErrorSystemFiles, "ErrorSystemFiles"}, + {Core::System::ResultStatus::ErrorSavestate, "ErrorSavestate"}, + {Core::System::ResultStatus::ErrorUnknown, "ErrorUnknown"}, + }; + + const auto name = CoreErrorNameMap.count(result) ? CoreErrorNameMap.at(result) : "ErrorUnknown"; + + JNIEnv* env = IDCache::GetEnvForThread(); + const jclass core_error_class = IDCache::GetCoreErrorClass(); + return env->GetStaticObjectField( + core_error_class, env->GetStaticFieldID(core_error_class, name, + "Lorg/citra/citra_emu/NativeLibrary$CoreError;")); +} + +static bool HandleCoreError(Core::System::ResultStatus result, const std::string& details) { + JNIEnv* env = IDCache::GetEnvForThread(); + return env->CallStaticBooleanMethod(IDCache::GetNativeLibraryClass(), IDCache::GetOnCoreError(), + ToJavaCoreError(result), + env->NewStringUTF(details.c_str())) != JNI_FALSE; +} + +static void LoadDiskCacheProgress(VideoCore::LoadCallbackStage stage, int progress, int max) { + JNIEnv* env = IDCache::GetEnvForThread(); + env->CallStaticVoidMethod(IDCache::GetDiskCacheProgressClass(), + IDCache::GetDiskCacheLoadProgress(), + IDCache::GetJavaLoadCallbackStage(stage), static_cast(progress), + static_cast(max)); +} + +static Camera::NDK::Factory* g_ndk_factory{}; + +static void TryShutdown() { + if (!window) { + return; + } + + window->DoneCurrent(); + Core::System::GetInstance().Shutdown(); + window.reset(); + InputManager::Shutdown(); + MicroProfileShutdown(); +} + +static Core::System::ResultStatus RunCitra(const std::string& filepath) { + // Citra core only supports a single running instance + std::lock_guard lock(running_mutex); + + LOG_INFO(Frontend, "Citra starting..."); + + MicroProfileOnThreadCreate("EmuThread"); + + if (filepath.empty()) { + LOG_CRITICAL(Frontend, "Failed to load ROM: No ROM specified"); + return Core::System::ResultStatus::ErrorLoader; + } + + window = std::make_unique(s_surf); + + Core::System& system{Core::System::GetInstance()}; + + // Forces a config reload on game boot, if the user changed settings in the UI + Config{}; + // Replace with game-specific settings + u64 program_id{}; + FileUtil::SetCurrentRomPath(filepath); + auto app_loader = Loader::GetLoader(filepath); + if (app_loader) { + app_loader->ReadProgramId(program_id); + GameSettings::LoadOverrides(program_id); + } + Settings::Apply(); + Settings::LogSettings(); + + Camera::RegisterFactory("image", std::make_unique()); + + auto ndk_factory = std::make_unique(); + g_ndk_factory = ndk_factory.get(); + Camera::RegisterFactory("ndk", std::move(ndk_factory)); + + // Register frontend applets + Frontend::RegisterDefaultApplets(); + system.RegisterMiiSelector(std::make_shared()); + system.RegisterSoftwareKeyboard(std::make_shared()); + + // Register real Mic factory + Frontend::Mic::RegisterRealMicFactory(std::make_unique()); + + InputManager::Init(); + + window->MakeCurrent(); + const Core::System::ResultStatus load_result{system.Load(*window, filepath)}; + if (load_result != Core::System::ResultStatus::Success) { + return load_result; + } + + auto& telemetry_session = Core::System::GetInstance().TelemetrySession(); + telemetry_session.AddField(Common::Telemetry::FieldType::App, "Frontend", "Android"); + + stop_run = false; + pause_emulation = false; + + LoadDiskCacheProgress(VideoCore::LoadCallbackStage::Prepare, 0, 0); + + std::unique_ptr cpu_context; + system.Renderer().Rasterizer()->LoadDiskResources(stop_run, &LoadDiskCacheProgress); + + LoadDiskCacheProgress(VideoCore::LoadCallbackStage::Complete, 0, 0); + + SCOPE_EXIT({ TryShutdown(); }); + + // Audio stretching on Android is only useful with lower framerates, disable it when fullspeed + Core::TimingEventType* audio_stretching_event{}; + const s64 audio_stretching_ticks{msToCycles(500)}; + audio_stretching_event = + system.CoreTiming().RegisterEvent("AudioStretchingEvent", [&](u64, s64 cycles_late) { + if (Settings::values.enable_audio_stretching) { + Core::DSP().EnableStretching( + Core::System::GetInstance().GetAndResetPerfStats().emulation_speed < 0.95); + } + + system.CoreTiming().ScheduleEvent(audio_stretching_ticks - cycles_late, + audio_stretching_event); + }); + system.CoreTiming().ScheduleEvent(audio_stretching_ticks, audio_stretching_event); + + // Start running emulation + while (!stop_run) { + if (!pause_emulation) { + const auto result = system.RunLoop(); + if (result == Core::System::ResultStatus::Success) { + continue; + } + if (result == Core::System::ResultStatus::ShutdownRequested) { + return result; // This also exits the emulation activity + } else { + InputManager::NDKMotionHandler()->DisableSensors(); + if (!HandleCoreError(result, system.GetStatusDetails())) { + // Frontend requests us to abort + return result; + } + InputManager::NDKMotionHandler()->EnableSensors(); + } + } else { + // Ensure no audio bleeds out while game is paused + const float volume = Settings::values.volume; + SCOPE_EXIT({ Settings::values.volume = volume; }); + Settings::values.volume = 0; + + std::unique_lock pause_lock(paused_mutex); + running_cv.wait(pause_lock, [] { return !pause_emulation || stop_run; }); + window->PollEvents(); + } + } + + return Core::System::ResultStatus::Success; +} + +extern "C" { + +void Java_org_citra_citra_1emu_NativeLibrary_SurfaceChanged(JNIEnv* env, + [[maybe_unused]] jclass clazz, + jobject surf) { + s_surf = ANativeWindow_fromSurface(env, surf); + + if (window) { + window->OnSurfaceChanged(s_surf); + } + + LOG_INFO(Frontend, "Surface changed"); +} + +void Java_org_citra_citra_1emu_NativeLibrary_SurfaceDestroyed(JNIEnv* env, + [[maybe_unused]] jclass clazz) { + ANativeWindow_release(s_surf); + s_surf = nullptr; + if (window) { + window->OnSurfaceChanged(s_surf); + } +} + +void Java_org_citra_citra_1emu_NativeLibrary_DoFrame(JNIEnv* env, [[maybe_unused]] jclass clazz) { + if (stop_run || pause_emulation) { + return; + } + window->TryPresenting(); +} + +void Java_org_citra_citra_1emu_NativeLibrary_NotifyOrientationChange(JNIEnv* env, + [[maybe_unused]] jclass clazz, + jint layout_option, + jint rotation) { + Settings::values.layout_option = static_cast(layout_option); + if (VideoCore::g_renderer) { + VideoCore::g_renderer->UpdateCurrentFramebufferLayout(!(rotation % 2)); + } + InputManager::screen_rotation = rotation; + Camera::NDK::g_rotation = rotation; +} + +void Java_org_citra_citra_1emu_NativeLibrary_SwapScreens(JNIEnv* env, [[maybe_unused]] jclass clazz, + jboolean swap_screens, jint rotation) { + Settings::values.swap_screen = swap_screens; + if (VideoCore::g_renderer) { + VideoCore::g_renderer->UpdateCurrentFramebufferLayout(!(rotation % 2)); + } + InputManager::screen_rotation = rotation; + Camera::NDK::g_rotation = rotation; +} + +void Java_org_citra_citra_1emu_NativeLibrary_SetUserDirectory(JNIEnv* env, + [[maybe_unused]] jclass clazz, + jstring j_directory) { + FileUtil::SetCurrentDir(GetJString(env, j_directory)); +} + +jobjectArray Java_org_citra_citra_1emu_NativeLibrary_GetInstalledGamePaths( + JNIEnv* env, [[maybe_unused]] jclass clazz) { + std::vector games; + const FileUtil::DirectoryEntryCallable ScanDir = + [&games, &ScanDir](u64*, const std::string& directory, const std::string& virtual_name) { + std::string path = directory + virtual_name; + if (FileUtil::IsDirectory(path)) { + path += '/'; + FileUtil::ForeachDirectoryEntry(nullptr, path, ScanDir); + } else { + auto loader = Loader::GetLoader(path); + if (loader) { + bool executable{}; + const Loader::ResultStatus result = loader->IsExecutable(executable); + if (Loader::ResultStatus::Success == result && executable) { + games.emplace_back(path); + } + } + } + return true; + }; + ScanDir(nullptr, "", + FileUtil::GetUserPath(FileUtil::UserPath::SDMCDir) + + "Nintendo " + "3DS/00000000000000000000000000000000/" + "00000000000000000000000000000000/title/00040000"); + ScanDir(nullptr, "", + FileUtil::GetUserPath(FileUtil::UserPath::NANDDir) + + "00000000000000000000000000000000/title/00040010"); + jobjectArray jgames = env->NewObjectArray(static_cast(games.size()), + env->FindClass("java/lang/String"), nullptr); + for (jsize i = 0; i < games.size(); ++i) + env->SetObjectArrayElement(jgames, i, env->NewStringUTF(games[i].c_str())); + return jgames; +} + +// TODO(xperia64): ensure these cannot be called in an invalid state (e.g. after StopEmulation) +void Java_org_citra_citra_1emu_NativeLibrary_UnPauseEmulation(JNIEnv* env, + [[maybe_unused]] jclass clazz) { + pause_emulation = false; + running_cv.notify_all(); + InputManager::NDKMotionHandler()->EnableSensors(); +} + +void Java_org_citra_citra_1emu_NativeLibrary_PauseEmulation(JNIEnv* env, + [[maybe_unused]] jclass clazz) { + pause_emulation = true; + InputManager::NDKMotionHandler()->DisableSensors(); +} + +void Java_org_citra_citra_1emu_NativeLibrary_StopEmulation(JNIEnv* env, + [[maybe_unused]] jclass clazz) { + stop_run = true; + pause_emulation = false; + window->StopPresenting(); + running_cv.notify_all(); +} + +jboolean Java_org_citra_citra_1emu_NativeLibrary_IsRunning(JNIEnv* env, + [[maybe_unused]] jclass clazz) { + return static_cast(!stop_run); +} + +jboolean Java_org_citra_citra_1emu_NativeLibrary_onGamePadEvent(JNIEnv* env, + [[maybe_unused]] jclass clazz, + jstring j_device, jint j_button, + jint action) { + bool consumed{}; + if (action) { + consumed = InputManager::ButtonHandler()->PressKey(j_button); + } else { + consumed = InputManager::ButtonHandler()->ReleaseKey(j_button); + } + + return static_cast(consumed); +} + +jboolean Java_org_citra_citra_1emu_NativeLibrary_onGamePadMoveEvent(JNIEnv* env, + [[maybe_unused]] jclass clazz, + jstring j_device, jint axis, + jfloat x, jfloat y) { + // Clamp joystick movement to supported minimum and maximum + // Citra uses an inverted y axis sent by the frontend + x = std::clamp(x, -1.f, 1.f); + y = std::clamp(-y, -1.f, 1.f); + + // Clamp the input to a circle (while touch input is already clamped in the frontend, gamepad is + // unknown) + float r = x * x + y * y; + if (r > 1.0f) { + r = std::sqrt(r); + x /= r; + y /= r; + } + return static_cast(InputManager::AnalogHandler()->MoveJoystick(axis, x, y)); +} + +jboolean Java_org_citra_citra_1emu_NativeLibrary_onGamePadAxisEvent(JNIEnv* env, + [[maybe_unused]] jclass clazz, + jstring j_device, jint axis_id, + jfloat axis_val) { + return static_cast( + InputManager::ButtonHandler()->AnalogButtonEvent(axis_id, axis_val)); +} + +jboolean Java_org_citra_citra_1emu_NativeLibrary_onTouchEvent(JNIEnv* env, + [[maybe_unused]] jclass clazz, + jfloat x, jfloat y, + jboolean pressed) { + return static_cast( + window->OnTouchEvent(static_cast(x + 0.5), static_cast(y + 0.5), pressed)); +} + +void Java_org_citra_citra_1emu_NativeLibrary_onTouchMoved(JNIEnv* env, + [[maybe_unused]] jclass clazz, jfloat x, + jfloat y) { + window->OnTouchMoved((int)x, (int)y); +} + +jintArray Java_org_citra_citra_1emu_NativeLibrary_GetIcon(JNIEnv* env, + [[maybe_unused]] jclass clazz, + jstring j_file) { + std::string filepath = GetJString(env, j_file); + + std::vector icon_data = GameInfo::GetIcon(filepath); + if (icon_data.size() == 0) { + return 0; + } + + jintArray icon = env->NewIntArray(static_cast(icon_data.size() / 2)); + env->SetIntArrayRegion(icon, 0, env->GetArrayLength(icon), + reinterpret_cast(icon_data.data())); + + return icon; +} + +jstring Java_org_citra_citra_1emu_NativeLibrary_GetTitle(JNIEnv* env, [[maybe_unused]] jclass clazz, + jstring j_filename) { + std::string filepath = GetJString(env, j_filename); + auto Title = GameInfo::GetTitle(filepath); + return env->NewStringUTF(Common::UTF16ToUTF8(Title).data()); +} + +jstring Java_org_citra_citra_1emu_NativeLibrary_GetDescription(JNIEnv* env, + [[maybe_unused]] jclass clazz, + jstring j_filename) { + return j_filename; +} + +jstring Java_org_citra_citra_1emu_NativeLibrary_GetGameId(JNIEnv* env, + [[maybe_unused]] jclass clazz, + jstring j_filename) { + return j_filename; +} + +jstring Java_org_citra_citra_1emu_NativeLibrary_GetRegions(JNIEnv* env, + [[maybe_unused]] jclass clazz, + jstring j_filename) { + std::string filepath = GetJString(env, j_filename); + + std::string regions = GameInfo::GetRegions(filepath); + + return env->NewStringUTF(regions.c_str()); +} + +jstring Java_org_citra_citra_1emu_NativeLibrary_GetCompany(JNIEnv* env, + [[maybe_unused]] jclass clazz, + jstring j_filename) { + std::string filepath = GetJString(env, j_filename); + auto publisher = GameInfo::GetPublisher(filepath); + return env->NewStringUTF(Common::UTF16ToUTF8(publisher).data()); +} + +jstring Java_org_citra_citra_1emu_NativeLibrary_GetGitRevision(JNIEnv* env, + [[maybe_unused]] jclass clazz) { + return nullptr; +} + +void Java_org_citra_citra_1emu_NativeLibrary_CreateConfigFile(JNIEnv* env, + [[maybe_unused]] jclass clazz) { + Config{}; +} + +jint Java_org_citra_citra_1emu_NativeLibrary_DefaultCPUCore(JNIEnv* env, + [[maybe_unused]] jclass clazz) { + return 0; +} + +void Java_org_citra_citra_1emu_NativeLibrary_Run__Ljava_lang_String_2Ljava_lang_String_2Z( + JNIEnv* env, [[maybe_unused]] jclass clazz, jstring j_file, jstring j_savestate, + jboolean j_delete_savestate) {} + +void Java_org_citra_citra_1emu_NativeLibrary_ReloadSettings(JNIEnv* env, + [[maybe_unused]] jclass clazz) { + Config{}; + Core::System& system{Core::System::GetInstance()}; + + // Replace with game-specific settings + if (system.IsPoweredOn()) { + u64 program_id{}; + system.GetAppLoader().ReadProgramId(program_id); + GameSettings::LoadOverrides(program_id); + } + + Settings::Apply(); +} + +jstring Java_org_citra_citra_1emu_NativeLibrary_GetUserSetting(JNIEnv* env, + [[maybe_unused]] jclass clazz, + jstring j_game_id, jstring j_section, + jstring j_key) { + std::string_view game_id = env->GetStringUTFChars(j_game_id, 0); + std::string_view section = env->GetStringUTFChars(j_section, 0); + std::string_view key = env->GetStringUTFChars(j_key, 0); + + // TODO + + env->ReleaseStringUTFChars(j_game_id, game_id.data()); + env->ReleaseStringUTFChars(j_section, section.data()); + env->ReleaseStringUTFChars(j_key, key.data()); + + return env->NewStringUTF(""); +} + +void Java_org_citra_citra_1emu_NativeLibrary_SetUserSetting(JNIEnv* env, + [[maybe_unused]] jclass clazz, + jstring j_game_id, jstring j_section, + jstring j_key, jstring j_value) { + std::string_view game_id = env->GetStringUTFChars(j_game_id, 0); + std::string_view section = env->GetStringUTFChars(j_section, 0); + std::string_view key = env->GetStringUTFChars(j_key, 0); + std::string_view value = env->GetStringUTFChars(j_value, 0); + + // TODO + + env->ReleaseStringUTFChars(j_game_id, game_id.data()); + env->ReleaseStringUTFChars(j_section, section.data()); + env->ReleaseStringUTFChars(j_key, key.data()); + env->ReleaseStringUTFChars(j_value, value.data()); +} + +void Java_org_citra_citra_1emu_NativeLibrary_InitGameIni(JNIEnv* env, [[maybe_unused]] jclass clazz, + jstring j_game_id) { + std::string_view game_id = env->GetStringUTFChars(j_game_id, 0); + + // TODO + + env->ReleaseStringUTFChars(j_game_id, game_id.data()); +} + +jdoubleArray Java_org_citra_citra_1emu_NativeLibrary_GetPerfStats(JNIEnv* env, + [[maybe_unused]] jclass clazz) { + auto& core = Core::System::GetInstance(); + jdoubleArray j_stats = env->NewDoubleArray(4); + + if (core.IsPoweredOn()) { + auto results = core.GetAndResetPerfStats(); + + // Converting the structure into an array makes it easier to pass it to the frontend + double stats[4] = {results.system_fps, results.game_fps, results.frametime, + results.emulation_speed}; + + env->SetDoubleArrayRegion(j_stats, 0, 4, stats); + } + + return j_stats; +} + +void Java_org_citra_citra_1emu_utils_DirectoryInitialization_SetSysDirectory( + JNIEnv* env, [[maybe_unused]] jclass clazz, jstring j_path) { + std::string_view path = env->GetStringUTFChars(j_path, 0); + + env->ReleaseStringUTFChars(j_path, path.data()); +} + +void Java_org_citra_citra_1emu_NativeLibrary_Run__Ljava_lang_String_2(JNIEnv* env, + [[maybe_unused]] jclass clazz, + jstring j_path) { + const std::string path = GetJString(env, j_path); + + if (!stop_run) { + stop_run = true; + running_cv.notify_all(); + } + + const Core::System::ResultStatus result{RunCitra(path)}; + if (result != Core::System::ResultStatus::Success) { + env->CallStaticVoidMethod(IDCache::GetNativeLibraryClass(), + IDCache::GetExitEmulationActivity(), static_cast(result)); + } +} + +jobjectArray Java_org_citra_citra_1emu_NativeLibrary_GetTextureFilterNames(JNIEnv* env, + jclass clazz) { + auto names = OpenGL::TextureFilterer::GetFilterNames(); + jobjectArray ret = (jobjectArray)env->NewObjectArray(static_cast(names.size()), + env->FindClass("java/lang/String"), + env->NewStringUTF("")); + for (jsize i = 0; i < names.size(); ++i) + env->SetObjectArrayElement(ret, i, env->NewStringUTF(names[i].data())); + return ret; +} + +void Java_org_citra_citra_1emu_NativeLibrary_ReloadCameraDevices(JNIEnv* env, jclass clazz) { + if (g_ndk_factory) { + g_ndk_factory->ReloadCameraDevices(); + } +} + +jboolean Java_org_citra_citra_1emu_NativeLibrary_LoadAmiibo(JNIEnv* env, jclass clazz, + jbyteArray bytes) { + Core::System& system{Core::System::GetInstance()}; + Service::SM::ServiceManager& sm = system.ServiceManager(); + auto nfc = sm.GetService("nfc:u"); + if (nfc == nullptr || env->GetArrayLength(bytes) != sizeof(Service::NFC::AmiiboData)) { + return static_cast(false); + } + + Service::NFC::AmiiboData amiibo_data{}; + env->GetByteArrayRegion(bytes, 0, sizeof(Service::NFC::AmiiboData), + reinterpret_cast(&amiibo_data)); + + nfc->LoadAmiibo(amiibo_data); + return static_cast(true); +} + +void Java_org_citra_citra_1emu_NativeLibrary_RemoveAmiibo(JNIEnv* env, jclass clazz) { + Core::System& system{Core::System::GetInstance()}; + Service::SM::ServiceManager& sm = system.ServiceManager(); + auto nfc = sm.GetService("nfc:u"); + if (nfc == nullptr) { + return; + } + + nfc->RemoveAmiibo(); +} + +void Java_org_citra_citra_1emu_NativeLibrary_InstallCIAS(JNIEnv* env, [[maybe_unused]] jclass clazz, + jobjectArray path) { + const jsize count{env->GetArrayLength(path)}; + std::vector paths; + for (jsize idx{0}; idx < count; ++idx) { + paths.emplace_back( + GetJString(env, static_cast(env->GetObjectArrayElement(path, idx)))); + } + std::atomic idx{count}; + std::vector threads; + std::generate_n(std::back_inserter(threads), + std::min(std::thread::hardware_concurrency(), count), [&] { + return std::thread{[&idx, &paths, env] { + jsize work_idx; + while ((work_idx = --idx) >= 0) { + LOG_INFO(Frontend, "Installing CIA {}", work_idx); + Service::AM::InstallCIA(paths[work_idx]); + } + }}; + }); + for (auto& thread : threads) + thread.join(); +} + +jobjectArray Java_org_citra_citra_1emu_NativeLibrary_GetSavestateInfo( + JNIEnv* env, [[maybe_unused]] jclass clazz) { + const jclass date_class = env->FindClass("java/util/Date"); + const auto date_constructor = env->GetMethodID(date_class, "", "(J)V"); + + const jclass savestate_info_class = IDCache::GetSavestateInfoClass(); + const auto slot_field = env->GetFieldID(savestate_info_class, "slot", "I"); + const auto date_field = env->GetFieldID(savestate_info_class, "time", "Ljava/util/Date;"); + + const Core::System& system{Core::System::GetInstance()}; + if (!system.IsPoweredOn()) { + return nullptr; + } + + u64 title_id; + if (system.GetAppLoader().ReadProgramId(title_id) != Loader::ResultStatus::Success) { + return nullptr; + } + + const auto savestates = Core::ListSaveStates(title_id); + const jobjectArray array = + env->NewObjectArray(static_cast(savestates.size()), savestate_info_class, nullptr); + for (std::size_t i = 0; i < savestates.size(); ++i) { + const jobject object = env->AllocObject(savestate_info_class); + env->SetIntField(object, slot_field, static_cast(savestates[i].slot)); + env->SetObjectField(object, date_field, + env->NewObject(date_class, date_constructor, + static_cast(savestates[i].time * 1000))); + + env->SetObjectArrayElement(array, i, object); + } + return array; +} + +void Java_org_citra_citra_1emu_NativeLibrary_SaveState(JNIEnv* env, jclass clazz, jint slot) { + Core::System::GetInstance().SendSignal(Core::System::Signal::Save, slot); +} + +void Java_org_citra_citra_1emu_NativeLibrary_LoadState(JNIEnv* env, jclass clazz, jint slot) { + Core::System::GetInstance().SendSignal(Core::System::Signal::Load, slot); +} + +void Java_org_citra_citra_1emu_NativeLibrary_LogDeviceInfo(JNIEnv* env, jclass clazz) { + // TODO: Log the Common::g_build_fullname once the CI is setup for android + LOG_INFO(Frontend, "Citra Version: Android Beta | {}-{}", Common::g_scm_branch, + Common::g_scm_desc); + LOG_INFO(Frontend, "Host CPU: {}", Common::GetCPUCaps().cpu_string); + // There is no decent way to get the OS version, so we log the API level instead. + LOG_INFO(Frontend, "Host OS: Android API level {}", android_get_device_api_level()); +} + +} // extern "C" diff --git a/src/android/app/src/main/jni/native.h b/src/android/app/src/main/jni/native.h new file mode 100644 index 000000000..52bfeeecb --- /dev/null +++ b/src/android/app/src/main/jni/native.h @@ -0,0 +1,163 @@ +// Copyright 2019 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#pragma once + +#include + +// Function calls from the Java side +#ifdef __cplusplus +extern "C" { +#endif + +JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_UnPauseEmulation(JNIEnv* env, + jclass clazz); + +JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_PauseEmulation(JNIEnv* env, + jclass clazz); + +JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_StopEmulation(JNIEnv* env, + jclass clazz); + +JNIEXPORT jboolean JNICALL Java_org_citra_citra_1emu_NativeLibrary_IsRunning(JNIEnv* env, + jclass clazz); + +JNIEXPORT jboolean JNICALL Java_org_citra_citra_1emu_NativeLibrary_onGamePadEvent( + JNIEnv* env, jclass clazz, jstring j_device, jint j_button, jint action); + +JNIEXPORT jboolean JNICALL Java_org_citra_citra_1emu_NativeLibrary_onGamePadMoveEvent( + JNIEnv* env, jclass clazz, jstring j_device, jint axis, jfloat x, jfloat y); + +JNIEXPORT jboolean JNICALL Java_org_citra_citra_1emu_NativeLibrary_onGamePadAxisEvent( + JNIEnv* env, jclass clazz, jstring j_device, jint axis_id, jfloat axis_val); + +JNIEXPORT jboolean JNICALL Java_org_citra_citra_1emu_NativeLibrary_onTouchEvent(JNIEnv* env, + jclass clazz, + jfloat x, jfloat y, + jboolean pressed); + +JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_onTouchMoved(JNIEnv* env, + jclass clazz, jfloat x, + jfloat y); + +JNIEXPORT jintArray JNICALL Java_org_citra_citra_1emu_NativeLibrary_GetIcon(JNIEnv* env, + jclass clazz, + jstring j_file); + +JNIEXPORT jstring JNICALL Java_org_citra_citra_1emu_NativeLibrary_GetTitle(JNIEnv* env, + jclass clazz, + jstring j_filename); + +JNIEXPORT jstring JNICALL Java_org_citra_citra_1emu_NativeLibrary_GetDescription( + JNIEnv* env, jclass clazz, jstring j_filename); + +JNIEXPORT jstring JNICALL Java_org_citra_citra_1emu_NativeLibrary_GetGameId(JNIEnv* env, + jclass clazz, + jstring j_filename); + +JNIEXPORT jstring JNICALL Java_org_citra_citra_1emu_NativeLibrary_GetRegions(JNIEnv* env, + jclass clazz, + jstring j_filename); + +JNIEXPORT jstring JNICALL Java_org_citra_citra_1emu_NativeLibrary_GetCompany(JNIEnv* env, + jclass clazz, + jstring j_filename); + +JNIEXPORT jstring JNICALL Java_org_citra_citra_1emu_NativeLibrary_GetGitRevision(JNIEnv* env, + jclass clazz); + +JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_SetUserDirectory( + JNIEnv* env, jclass clazz, jstring j_directory); + +JNIEXPORT jobjectArray JNICALL +Java_org_citra_citra_1emu_NativeLibrary_GetInstalledGamePaths(JNIEnv* env, jclass clazz); + +JNIEXPORT void JNICALL Java_org_citra_citra_1emu_utils_DirectoryInitialization_SetSysDirectory( + JNIEnv* env, jclass clazz, jstring path_); + +JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_SetSysDirectory(JNIEnv* env, + jclass clazz, + jstring path); + +JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_CreateConfigFile(JNIEnv* env, + jclass clazz); + +JNIEXPORT jint JNICALL Java_org_citra_citra_1emu_NativeLibrary_DefaultCPUCore(JNIEnv* env, + jclass clazz); +JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_SetProfiling(JNIEnv* env, + jclass clazz, + jboolean enable); + +JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_WriteProfileResults(JNIEnv* env, + jclass clazz); + +JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_NotifyOrientationChange( + JNIEnv* env, jclass clazz, jint layout_option, jint rotation); + +JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_SwapScreens(JNIEnv* env, + jclass clazz, + jboolean swap_screens, + jint rotation); + +JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_Run__Ljava_lang_String_2( + JNIEnv* env, jclass clazz, jstring j_path); + +JNIEXPORT void JNICALL +Java_org_citra_citra_1emu_NativeLibrary_Run__Ljava_lang_String_2Ljava_lang_String_2Z( + JNIEnv* env, jclass clazz, jstring j_file, jstring j_savestate, jboolean j_delete_savestate); + +JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_SurfaceChanged(JNIEnv* env, + jclass clazz, + jobject surf); + +JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_SurfaceDestroyed(JNIEnv* env, + jclass clazz); + +JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_InitGameIni(JNIEnv* env, + jclass clazz, + jstring j_game_id); + +JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_ReloadSettings(JNIEnv* env, + jclass clazz); + +JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_SetUserSetting( + JNIEnv* env, jclass clazz, jstring j_game_id, jstring j_section, jstring j_key, + jstring j_value); + +JNIEXPORT jstring JNICALL Java_org_citra_citra_1emu_NativeLibrary_GetUserSetting( + JNIEnv* env, jclass clazz, jstring game_id, jstring section, jstring key); + +JNIEXPORT jdoubleArray JNICALL Java_org_citra_citra_1emu_NativeLibrary_GetPerfStats(JNIEnv* env, + jclass clazz); + +JNIEXPORT jobjectArray JNICALL +Java_org_citra_citra_1emu_NativeLibrary_GetTextureFilterNames(JNIEnv* env, jclass clazz); + +JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_ReloadCameraDevices(JNIEnv* env, + jclass clazz); + +JNIEXPORT jboolean Java_org_citra_citra_1emu_NativeLibrary_LoadAmiibo(JNIEnv* env, jclass clazz, + jbyteArray bytes); + +JNIEXPORT void Java_org_citra_citra_1emu_NativeLibrary_RemoveAmiibo(JNIEnv* env, jclass clazz); + +JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_InstallCIAS(JNIEnv* env, + jclass clazz, + jobjectArray path); + +JNIEXPORT jobjectArray JNICALL +Java_org_citra_citra_1emu_NativeLibrary_GetSavestateInfo(JNIEnv* env, jclass clazz); + +JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_SaveState(JNIEnv* env, jclass clazz, + jint slot); + +JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_LoadState(JNIEnv* env, jclass clazz, + jint slot); + +JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_LogDeviceInfo(JNIEnv* env, + jclass clazz); + +#ifdef __cplusplus +} +#endif diff --git a/src/android/app/src/main/jni/ndk_motion.cpp b/src/android/app/src/main/jni/ndk_motion.cpp new file mode 100644 index 000000000..0eab444a9 --- /dev/null +++ b/src/android/app/src/main/jni/ndk_motion.cpp @@ -0,0 +1,196 @@ +#include +#include + +#include + +#include "common/assert.h" +#include "common/logging/log.h" +#include "common/vector_math.h" +#include "jni/native.h" +#include "jni/ndk_motion.h" + +namespace InputManager { + +namespace { +using Common::Vec3; +} + +class NDKMotion final : public Input::MotionDevice { + std::chrono::microseconds update_period; + + ASensorManager* sensor_manager = nullptr; + ALooper* looper = nullptr; + ASensorEventQueue* event_queue; + + mutable std::atomic> acceleration{}; + mutable std::atomic> rotation{}; + static_assert(decltype(acceleration)::is_always_lock_free, "vectors are not lock free"); + std::thread poll_thread; + std::atomic stop_polling = false; + + static Vec3 TransformAxes(Vec3 in) { + // 3DS Y+ Phone Z+ + // on | laying | + // table | in | + // |_______ X- portrait |_______ X+ + // / mode / + // / / + // Z- Y- + Vec3 out; + out.y = in.z; + // rotations are 90 degrees counter-clockwise from portrait + switch (screen_rotation) { + case 0: + out.x = -in.x; + out.z = in.y; + break; + case 1: + out.x = in.y; + out.z = in.x; + break; + case 2: + out.x = in.x; + out.z = -in.y; + break; + case 3: + out.x = -in.y; + out.z = -in.x; + break; + default: + UNREACHABLE(); + } + return out; + } + + void Construct() { + sensor_manager = ASensorManager_getInstanceForPackage("org.citra.citra_emu"); + looper = ALooper_prepare(ALOOPER_PREPARE_ALLOW_NON_CALLBACKS); + if (!sensor_manager || !looper) { + LOG_CRITICAL(Input, "Could not retrieve sensor manager"); + return; + } + event_queue = ASensorManager_createEventQueue(sensor_manager, looper, 0, nullptr, nullptr); + if (!event_queue) { + LOG_ERROR(Input, "Could not create sensor event queue"); + return; + } + + EnableSensors(); + } + + void Destruct() { + ASensorManager_destroyEventQueue(sensor_manager, event_queue); + } + + void Update() const { + ALooper_pollAll(0, nullptr, nullptr, nullptr); + ASensorEvent event{}; + std::optional> new_accel{}, new_rot{}; + while (ASensorEventQueue_getEvents(event_queue, &event, 1) > 0) { + if (event.type == ASENSOR_TYPE_ACCELEROMETER) { + new_accel.emplace(event.vector.x, event.vector.y, event.vector.z); + } else if (event.type == ASENSOR_TYPE_GYROSCOPE) { + new_rot.emplace(event.vector.x, event.vector.y, event.vector.z); + } + // occasionally the queue has ASENSOR_TYPE_ADDITIONAL_INFO events + // but so far there is no reason to handle them + } + if (new_accel) { + // convert from m/(s^2) to g and invert + acceleration = TransformAxes(*new_accel) / -ASENSOR_STANDARD_GRAVITY; + } + if (new_rot) { + // convert from rad/s to deg/s + rotation = TransformAxes(*new_rot) * 180.0f / static_cast(M_PI); + } + } + +public: + NDKMotion(std::chrono::microseconds update_period_, bool asynchronous = false) + : update_period(update_period_) { + if (asynchronous) { + poll_thread = std::thread([this] { + Construct(); + auto start = std::chrono::high_resolution_clock::now(); + while (!stop_polling) { + Update(); + std::this_thread::sleep_until(start += update_period); + } + Destruct(); + }); + } else { + Construct(); + } + } + + ~NDKMotion() { + if (std::thread::id{} == poll_thread.get_id()) { + Destruct(); + } else { + stop_polling = true; + poll_thread.join(); + } + } + + std::tuple, Vec3> GetStatus() const override { + if (std::thread::id{} == poll_thread.get_id()) { + Update(); + } + return {acceleration, rotation}; + } + + void EnableSensors() { + const auto init_sensor = [this](int sensor_type) { + ASensorRef sensor = ASensorManager_getDefaultSensor(sensor_manager, sensor_type); + if (!sensor) { + LOG_ERROR(Input, "Could not find sensor of type {}", sensor_type); + return; + } + int error = ASensorEventQueue_registerSensor( + event_queue, sensor, + std::max(ASensor_getMinDelay(sensor), static_cast(update_period.count())), 0); + if (error < 0) + LOG_ERROR(Input, "Registering sensor returned error code {}", error); + }; + + LOG_TRACE(Input, "Enabling sensors.."); + init_sensor(ASENSOR_TYPE_ACCELEROMETER); + init_sensor(ASENSOR_TYPE_GYROSCOPE); + } + + void DisableSensors() { + const auto disable_sensor = [this](int sensor_type) { + ASensorRef sensor = ASensorManager_getDefaultSensor(sensor_manager, sensor_type); + if (!sensor) { + LOG_ERROR(Input, "Could not find sensor of type {}", sensor_type); + return; + } + int error = ASensorEventQueue_disableSensor(event_queue, sensor); + if (error < 0) + LOG_ERROR(Input, "Disabling sensor returned error code {}", error); + }; + + LOG_TRACE(Input, "Disabling sensors.."); + disable_sensor(ASENSOR_TYPE_ACCELEROMETER); + disable_sensor(ASENSOR_TYPE_GYROSCOPE); + } +}; + +std::unique_ptr NDKMotionFactory::Create(const Common::ParamPackage& params) { + std::chrono::milliseconds update_period{params.Get("update_period", 4)}; + std::unique_ptr ndk_motion = std::make_unique(update_period); + ndk_motion_device = ndk_motion.get(); + return std::move(ndk_motion); +} + +void NDKMotionFactory::EnableSensors() { + if (ndk_motion_device) + ndk_motion_device->EnableSensors(); +} + +void NDKMotionFactory::DisableSensors() { + if (ndk_motion_device) + ndk_motion_device->DisableSensors(); +} + +} // namespace InputManager diff --git a/src/android/app/src/main/jni/ndk_motion.h b/src/android/app/src/main/jni/ndk_motion.h new file mode 100644 index 000000000..01a8f8d9c --- /dev/null +++ b/src/android/app/src/main/jni/ndk_motion.h @@ -0,0 +1,28 @@ +// Copyright 2020 Citra Emulator Project +// Licensed under GPLv2+ +// Refer to the license.txt file included. + +#pragma once + +#include "core/frontend/input.h" + +namespace InputManager { + +inline std::atomic screen_rotation; + +class NDKMotion; + +class NDKMotionFactory final : public Input::Factory { +public: + /** + * Creates a motion device that obtains data from device sensors + */ + std::unique_ptr Create(const Common::ParamPackage& params) override; + + void EnableSensors(); + void DisableSensors(); + +private: + NDKMotion* ndk_motion_device; +}; +} // namespace InputManager diff --git a/src/android/app/src/main/res/animator/settings_enter.xml b/src/android/app/src/main/res/animator/settings_enter.xml new file mode 100644 index 000000000..3c216a054 --- /dev/null +++ b/src/android/app/src/main/res/animator/settings_enter.xml @@ -0,0 +1,28 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/android/app/src/main/res/animator/settings_exit.xml b/src/android/app/src/main/res/animator/settings_exit.xml new file mode 100644 index 000000000..a233b6757 --- /dev/null +++ b/src/android/app/src/main/res/animator/settings_exit.xml @@ -0,0 +1,28 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/android/app/src/main/res/animator/settings_pop_enter.xml b/src/android/app/src/main/res/animator/settings_pop_enter.xml new file mode 100644 index 000000000..080bc27c4 --- /dev/null +++ b/src/android/app/src/main/res/animator/settings_pop_enter.xml @@ -0,0 +1,28 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/android/app/src/main/res/animator/setttings_pop_exit.xml b/src/android/app/src/main/res/animator/setttings_pop_exit.xml new file mode 100644 index 000000000..4fccbcca2 --- /dev/null +++ b/src/android/app/src/main/res/animator/setttings_pop_exit.xml @@ -0,0 +1,27 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/android/app/src/main/res/drawable-hdpi/button_a.png b/src/android/app/src/main/res/drawable-hdpi/button_a.png new file mode 100644 index 000000000..f96a2061e Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/button_a.png differ diff --git a/src/android/app/src/main/res/drawable-hdpi/button_a_pressed.png b/src/android/app/src/main/res/drawable-hdpi/button_a_pressed.png new file mode 100644 index 000000000..785a258ee Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/button_a_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-hdpi/button_b.png b/src/android/app/src/main/res/drawable-hdpi/button_b.png new file mode 100644 index 000000000..b15d2b549 Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/button_b.png differ diff --git a/src/android/app/src/main/res/drawable-hdpi/button_b_pressed.png b/src/android/app/src/main/res/drawable-hdpi/button_b_pressed.png new file mode 100644 index 000000000..b11d5fcee Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/button_b_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-hdpi/button_l.png b/src/android/app/src/main/res/drawable-hdpi/button_l.png new file mode 100644 index 000000000..e19469a7b Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/button_l.png differ diff --git a/src/android/app/src/main/res/drawable-hdpi/button_l_pressed.png b/src/android/app/src/main/res/drawable-hdpi/button_l_pressed.png new file mode 100644 index 000000000..280857f64 Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/button_l_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-hdpi/button_r.png b/src/android/app/src/main/res/drawable-hdpi/button_r.png new file mode 100644 index 000000000..f72cdc1dc Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/button_r.png differ diff --git a/src/android/app/src/main/res/drawable-hdpi/button_r_pressed.png b/src/android/app/src/main/res/drawable-hdpi/button_r_pressed.png new file mode 100644 index 000000000..c47d34253 Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/button_r_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-hdpi/button_select.png b/src/android/app/src/main/res/drawable-hdpi/button_select.png new file mode 100644 index 000000000..6961b88d2 Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/button_select.png differ diff --git a/src/android/app/src/main/res/drawable-hdpi/button_select_pressed.png b/src/android/app/src/main/res/drawable-hdpi/button_select_pressed.png new file mode 100644 index 000000000..8ee471419 Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/button_select_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-hdpi/button_start.png b/src/android/app/src/main/res/drawable-hdpi/button_start.png new file mode 100644 index 000000000..72856cf47 Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/button_start.png differ diff --git a/src/android/app/src/main/res/drawable-hdpi/button_start_pressed.png b/src/android/app/src/main/res/drawable-hdpi/button_start_pressed.png new file mode 100644 index 000000000..f96cd3359 Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/button_start_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-hdpi/button_x.png b/src/android/app/src/main/res/drawable-hdpi/button_x.png new file mode 100644 index 000000000..1a0fd1924 Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/button_x.png differ diff --git a/src/android/app/src/main/res/drawable-hdpi/button_x_pressed.png b/src/android/app/src/main/res/drawable-hdpi/button_x_pressed.png new file mode 100644 index 000000000..089cb3af1 Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/button_x_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-hdpi/button_y.png b/src/android/app/src/main/res/drawable-hdpi/button_y.png new file mode 100644 index 000000000..bc22680c4 Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/button_y.png differ diff --git a/src/android/app/src/main/res/drawable-hdpi/button_y_pressed.png b/src/android/app/src/main/res/drawable-hdpi/button_y_pressed.png new file mode 100644 index 000000000..6e9e89ec9 Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/button_y_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-hdpi/button_zl.png b/src/android/app/src/main/res/drawable-hdpi/button_zl.png new file mode 100644 index 000000000..dd5d4d5b3 Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/button_zl.png differ diff --git a/src/android/app/src/main/res/drawable-hdpi/button_zl_pressed.png b/src/android/app/src/main/res/drawable-hdpi/button_zl_pressed.png new file mode 100644 index 000000000..8cd395f3b Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/button_zl_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-hdpi/button_zr.png b/src/android/app/src/main/res/drawable-hdpi/button_zr.png new file mode 100644 index 000000000..728fcf4d1 Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/button_zr.png differ diff --git a/src/android/app/src/main/res/drawable-hdpi/button_zr_pressed.png b/src/android/app/src/main/res/drawable-hdpi/button_zr_pressed.png new file mode 100644 index 000000000..121877610 Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/button_zr_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-hdpi/dpad.png b/src/android/app/src/main/res/drawable-hdpi/dpad.png new file mode 100644 index 000000000..921b3902d Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/dpad.png differ diff --git a/src/android/app/src/main/res/drawable-hdpi/dpad_pressed_one_direction.png b/src/android/app/src/main/res/drawable-hdpi/dpad_pressed_one_direction.png new file mode 100644 index 000000000..a8ffbb48a Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/dpad_pressed_one_direction.png differ diff --git a/src/android/app/src/main/res/drawable-hdpi/dpad_pressed_two_directions.png b/src/android/app/src/main/res/drawable-hdpi/dpad_pressed_two_directions.png new file mode 100644 index 000000000..ceb994a6d Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/dpad_pressed_two_directions.png differ diff --git a/src/android/app/src/main/res/drawable-hdpi/ic_cia_install.png b/src/android/app/src/main/res/drawable-hdpi/ic_cia_install.png new file mode 100644 index 000000000..8c00d8c34 Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/ic_cia_install.png differ diff --git a/src/android/app/src/main/res/drawable-hdpi/ic_folder.png b/src/android/app/src/main/res/drawable-hdpi/ic_folder.png new file mode 100644 index 000000000..90085252b Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/ic_folder.png differ diff --git a/src/android/app/src/main/res/drawable-hdpi/ic_premium.png b/src/android/app/src/main/res/drawable-hdpi/ic_premium.png new file mode 100644 index 000000000..7dd45a405 Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/ic_premium.png differ diff --git a/src/android/app/src/main/res/drawable-hdpi/ic_settings_core.png b/src/android/app/src/main/res/drawable-hdpi/ic_settings_core.png new file mode 100644 index 000000000..2e7837020 Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/ic_settings_core.png differ diff --git a/src/android/app/src/main/res/drawable-hdpi/ic_stat_notification_logo.png b/src/android/app/src/main/res/drawable-hdpi/ic_stat_notification_logo.png new file mode 100644 index 000000000..2282f1a3b Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/ic_stat_notification_logo.png differ diff --git a/src/android/app/src/main/res/drawable-hdpi/stick_c.png b/src/android/app/src/main/res/drawable-hdpi/stick_c.png new file mode 100644 index 000000000..d4c1d6c97 Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/stick_c.png differ diff --git a/src/android/app/src/main/res/drawable-hdpi/stick_c_pressed.png b/src/android/app/src/main/res/drawable-hdpi/stick_c_pressed.png new file mode 100644 index 000000000..c8d14c029 Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/stick_c_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-hdpi/stick_c_range.png b/src/android/app/src/main/res/drawable-hdpi/stick_c_range.png new file mode 100644 index 000000000..8263d4b8d Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/stick_c_range.png differ diff --git a/src/android/app/src/main/res/drawable-hdpi/stick_main.png b/src/android/app/src/main/res/drawable-hdpi/stick_main.png new file mode 100644 index 000000000..ae6d025a5 Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/stick_main.png differ diff --git a/src/android/app/src/main/res/drawable-hdpi/stick_main_pressed.png b/src/android/app/src/main/res/drawable-hdpi/stick_main_pressed.png new file mode 100644 index 000000000..ca469c6a7 Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/stick_main_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-hdpi/stick_main_range.png b/src/android/app/src/main/res/drawable-hdpi/stick_main_range.png new file mode 100644 index 000000000..9b5445edc Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/stick_main_range.png differ diff --git a/src/android/app/src/main/res/drawable-mdpi/ic_cia_install.png b/src/android/app/src/main/res/drawable-mdpi/ic_cia_install.png new file mode 100644 index 000000000..c6dc232b4 Binary files /dev/null and b/src/android/app/src/main/res/drawable-mdpi/ic_cia_install.png differ diff --git a/src/android/app/src/main/res/drawable-mdpi/ic_folder.png b/src/android/app/src/main/res/drawable-mdpi/ic_folder.png new file mode 100644 index 000000000..1e428dfe3 Binary files /dev/null and b/src/android/app/src/main/res/drawable-mdpi/ic_folder.png differ diff --git a/src/android/app/src/main/res/drawable-mdpi/ic_premium.png b/src/android/app/src/main/res/drawable-mdpi/ic_premium.png new file mode 100644 index 000000000..4dfb62596 Binary files /dev/null and b/src/android/app/src/main/res/drawable-mdpi/ic_premium.png differ diff --git a/src/android/app/src/main/res/drawable-night-hdpi/ic_cia_install.png b/src/android/app/src/main/res/drawable-night-hdpi/ic_cia_install.png new file mode 100644 index 000000000..cc986c8ac Binary files /dev/null and b/src/android/app/src/main/res/drawable-night-hdpi/ic_cia_install.png differ diff --git a/src/android/app/src/main/res/drawable-night-hdpi/ic_folder.png b/src/android/app/src/main/res/drawable-night-hdpi/ic_folder.png new file mode 100644 index 000000000..ee688b09f Binary files /dev/null and b/src/android/app/src/main/res/drawable-night-hdpi/ic_folder.png differ diff --git a/src/android/app/src/main/res/drawable-night-hdpi/ic_premium.png b/src/android/app/src/main/res/drawable-night-hdpi/ic_premium.png new file mode 100644 index 000000000..6b678d22c Binary files /dev/null and b/src/android/app/src/main/res/drawable-night-hdpi/ic_premium.png differ diff --git a/src/android/app/src/main/res/drawable-night-hdpi/ic_settings_core.png b/src/android/app/src/main/res/drawable-night-hdpi/ic_settings_core.png new file mode 100644 index 000000000..bc9dc0beb Binary files /dev/null and b/src/android/app/src/main/res/drawable-night-hdpi/ic_settings_core.png differ diff --git a/src/android/app/src/main/res/drawable-night-mdpi/ic_cia_install.png b/src/android/app/src/main/res/drawable-night-mdpi/ic_cia_install.png new file mode 100644 index 000000000..f61d84961 Binary files /dev/null and b/src/android/app/src/main/res/drawable-night-mdpi/ic_cia_install.png differ diff --git a/src/android/app/src/main/res/drawable-night-mdpi/ic_folder.png b/src/android/app/src/main/res/drawable-night-mdpi/ic_folder.png new file mode 100644 index 000000000..05847c34b Binary files /dev/null and b/src/android/app/src/main/res/drawable-night-mdpi/ic_folder.png differ diff --git a/src/android/app/src/main/res/drawable-night-mdpi/ic_premium.png b/src/android/app/src/main/res/drawable-night-mdpi/ic_premium.png new file mode 100644 index 000000000..87bac27df Binary files /dev/null and b/src/android/app/src/main/res/drawable-night-mdpi/ic_premium.png differ diff --git a/src/android/app/src/main/res/drawable-night-xhdpi/ic_cia_install.png b/src/android/app/src/main/res/drawable-night-xhdpi/ic_cia_install.png new file mode 100644 index 000000000..1eccbe68d Binary files /dev/null and b/src/android/app/src/main/res/drawable-night-xhdpi/ic_cia_install.png differ diff --git a/src/android/app/src/main/res/drawable-night-xhdpi/ic_folder.png b/src/android/app/src/main/res/drawable-night-xhdpi/ic_folder.png new file mode 100644 index 000000000..ffa1d200e Binary files /dev/null and b/src/android/app/src/main/res/drawable-night-xhdpi/ic_folder.png differ diff --git a/src/android/app/src/main/res/drawable-night-xhdpi/ic_premium.png b/src/android/app/src/main/res/drawable-night-xhdpi/ic_premium.png new file mode 100644 index 000000000..23a5cec51 Binary files /dev/null and b/src/android/app/src/main/res/drawable-night-xhdpi/ic_premium.png differ diff --git a/src/android/app/src/main/res/drawable-night-xhdpi/ic_settings_core.png b/src/android/app/src/main/res/drawable-night-xhdpi/ic_settings_core.png new file mode 100644 index 000000000..9ca7975bb Binary files /dev/null and b/src/android/app/src/main/res/drawable-night-xhdpi/ic_settings_core.png differ diff --git a/src/android/app/src/main/res/drawable-night-xxhdpi/ic_cia_install.png b/src/android/app/src/main/res/drawable-night-xxhdpi/ic_cia_install.png new file mode 100644 index 000000000..fc3c434b0 Binary files /dev/null and b/src/android/app/src/main/res/drawable-night-xxhdpi/ic_cia_install.png differ diff --git a/src/android/app/src/main/res/drawable-night-xxhdpi/ic_folder.png b/src/android/app/src/main/res/drawable-night-xxhdpi/ic_folder.png new file mode 100644 index 000000000..013600d1f Binary files /dev/null and b/src/android/app/src/main/res/drawable-night-xxhdpi/ic_folder.png differ diff --git a/src/android/app/src/main/res/drawable-night-xxhdpi/ic_premium.png b/src/android/app/src/main/res/drawable-night-xxhdpi/ic_premium.png new file mode 100644 index 000000000..2a0f1568f Binary files /dev/null and b/src/android/app/src/main/res/drawable-night-xxhdpi/ic_premium.png differ diff --git a/src/android/app/src/main/res/drawable-night-xxhdpi/ic_settings_core.png b/src/android/app/src/main/res/drawable-night-xxhdpi/ic_settings_core.png new file mode 100644 index 000000000..23706188b Binary files /dev/null and b/src/android/app/src/main/res/drawable-night-xxhdpi/ic_settings_core.png differ diff --git a/src/android/app/src/main/res/drawable-night-xxxhdpi/ic_cia_install.png b/src/android/app/src/main/res/drawable-night-xxxhdpi/ic_cia_install.png new file mode 100644 index 000000000..b4d1b92b7 Binary files /dev/null and b/src/android/app/src/main/res/drawable-night-xxxhdpi/ic_cia_install.png differ diff --git a/src/android/app/src/main/res/drawable-night-xxxhdpi/ic_folder.png b/src/android/app/src/main/res/drawable-night-xxxhdpi/ic_folder.png new file mode 100644 index 000000000..166bd052d Binary files /dev/null and b/src/android/app/src/main/res/drawable-night-xxxhdpi/ic_folder.png differ diff --git a/src/android/app/src/main/res/drawable-night-xxxhdpi/ic_premium.png b/src/android/app/src/main/res/drawable-night-xxxhdpi/ic_premium.png new file mode 100644 index 000000000..8d357b228 Binary files /dev/null and b/src/android/app/src/main/res/drawable-night-xxxhdpi/ic_premium.png differ diff --git a/src/android/app/src/main/res/drawable-night-xxxhdpi/ic_settings_core.png b/src/android/app/src/main/res/drawable-night-xxxhdpi/ic_settings_core.png new file mode 100644 index 000000000..32eb4faff Binary files /dev/null and b/src/android/app/src/main/res/drawable-night-xxxhdpi/ic_settings_core.png differ diff --git a/src/android/app/src/main/res/drawable-night/no_icon.png b/src/android/app/src/main/res/drawable-night/no_icon.png new file mode 100644 index 000000000..9a3969709 Binary files /dev/null and b/src/android/app/src/main/res/drawable-night/no_icon.png differ diff --git a/src/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/src/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml deleted file mode 100644 index c7bd21dbd..000000000 --- a/src/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - - - - diff --git a/src/android/app/src/main/res/drawable-xhdpi/button_a.png b/src/android/app/src/main/res/drawable-xhdpi/button_a.png new file mode 100644 index 000000000..4e20f2b0e Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/button_a.png differ diff --git a/src/android/app/src/main/res/drawable-xhdpi/button_a_pressed.png b/src/android/app/src/main/res/drawable-xhdpi/button_a_pressed.png new file mode 100644 index 000000000..f18edd07e Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/button_a_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xhdpi/button_b.png b/src/android/app/src/main/res/drawable-xhdpi/button_b.png new file mode 100644 index 000000000..deb83a09d Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/button_b.png differ diff --git a/src/android/app/src/main/res/drawable-xhdpi/button_b_pressed.png b/src/android/app/src/main/res/drawable-xhdpi/button_b_pressed.png new file mode 100644 index 000000000..f583be028 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/button_b_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xhdpi/button_l.png b/src/android/app/src/main/res/drawable-xhdpi/button_l.png new file mode 100644 index 000000000..d24039fbf Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/button_l.png differ diff --git a/src/android/app/src/main/res/drawable-xhdpi/button_l_pressed.png b/src/android/app/src/main/res/drawable-xhdpi/button_l_pressed.png new file mode 100644 index 000000000..378ac8751 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/button_l_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xhdpi/button_r.png b/src/android/app/src/main/res/drawable-xhdpi/button_r.png new file mode 100644 index 000000000..7b01c043e Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/button_r.png differ diff --git a/src/android/app/src/main/res/drawable-xhdpi/button_r_pressed.png b/src/android/app/src/main/res/drawable-xhdpi/button_r_pressed.png new file mode 100644 index 000000000..9b3e3e75a Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/button_r_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xhdpi/button_select.png b/src/android/app/src/main/res/drawable-xhdpi/button_select.png new file mode 100644 index 000000000..57abf5666 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/button_select.png differ diff --git a/src/android/app/src/main/res/drawable-xhdpi/button_select_pressed.png b/src/android/app/src/main/res/drawable-xhdpi/button_select_pressed.png new file mode 100644 index 000000000..29eda72af Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/button_select_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xhdpi/button_start.png b/src/android/app/src/main/res/drawable-xhdpi/button_start.png new file mode 100644 index 000000000..f9cf0d667 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/button_start.png differ diff --git a/src/android/app/src/main/res/drawable-xhdpi/button_start_pressed.png b/src/android/app/src/main/res/drawable-xhdpi/button_start_pressed.png new file mode 100644 index 000000000..4d690fa7e Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/button_start_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xhdpi/button_x.png b/src/android/app/src/main/res/drawable-xhdpi/button_x.png new file mode 100644 index 000000000..93a2ee997 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/button_x.png differ diff --git a/src/android/app/src/main/res/drawable-xhdpi/button_x_pressed.png b/src/android/app/src/main/res/drawable-xhdpi/button_x_pressed.png new file mode 100644 index 000000000..6bbd39646 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/button_x_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xhdpi/button_y.png b/src/android/app/src/main/res/drawable-xhdpi/button_y.png new file mode 100644 index 000000000..d979e98e0 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/button_y.png differ diff --git a/src/android/app/src/main/res/drawable-xhdpi/button_y_pressed.png b/src/android/app/src/main/res/drawable-xhdpi/button_y_pressed.png new file mode 100644 index 000000000..a6c9bdb54 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/button_y_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xhdpi/button_zl.png b/src/android/app/src/main/res/drawable-xhdpi/button_zl.png new file mode 100644 index 000000000..f94474fea Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/button_zl.png differ diff --git a/src/android/app/src/main/res/drawable-xhdpi/button_zl_pressed.png b/src/android/app/src/main/res/drawable-xhdpi/button_zl_pressed.png new file mode 100644 index 000000000..8f7d5ab7a Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/button_zl_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xhdpi/button_zr.png b/src/android/app/src/main/res/drawable-xhdpi/button_zr.png new file mode 100644 index 000000000..a76658351 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/button_zr.png differ diff --git a/src/android/app/src/main/res/drawable-xhdpi/button_zr_pressed.png b/src/android/app/src/main/res/drawable-xhdpi/button_zr_pressed.png new file mode 100644 index 000000000..bbe4e64ce Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/button_zr_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xhdpi/dpad.png b/src/android/app/src/main/res/drawable-xhdpi/dpad.png new file mode 100644 index 000000000..94ae84405 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/dpad.png differ diff --git a/src/android/app/src/main/res/drawable-xhdpi/dpad_pressed_one_direction.png b/src/android/app/src/main/res/drawable-xhdpi/dpad_pressed_one_direction.png new file mode 100644 index 000000000..d6ccb2c4f Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/dpad_pressed_one_direction.png differ diff --git a/src/android/app/src/main/res/drawable-xhdpi/dpad_pressed_two_directions.png b/src/android/app/src/main/res/drawable-xhdpi/dpad_pressed_two_directions.png new file mode 100644 index 000000000..2bba7749e Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/dpad_pressed_two_directions.png differ diff --git a/src/android/app/src/main/res/drawable-xhdpi/ic_cia_install.png b/src/android/app/src/main/res/drawable-xhdpi/ic_cia_install.png new file mode 100644 index 000000000..839869401 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/ic_cia_install.png differ diff --git a/src/android/app/src/main/res/drawable-xhdpi/ic_folder.png b/src/android/app/src/main/res/drawable-xhdpi/ic_folder.png new file mode 100644 index 000000000..02bc3d75a Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/ic_folder.png differ diff --git a/src/android/app/src/main/res/drawable-xhdpi/ic_premium.png b/src/android/app/src/main/res/drawable-xhdpi/ic_premium.png new file mode 100644 index 000000000..ac4b19ff4 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/ic_premium.png differ diff --git a/src/android/app/src/main/res/drawable-xhdpi/ic_settings_core.png b/src/android/app/src/main/res/drawable-xhdpi/ic_settings_core.png new file mode 100644 index 000000000..8cff45f84 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/ic_settings_core.png differ diff --git a/src/android/app/src/main/res/drawable-xhdpi/ic_stat_notification_logo.png b/src/android/app/src/main/res/drawable-xhdpi/ic_stat_notification_logo.png new file mode 100644 index 000000000..5e2787ba3 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/ic_stat_notification_logo.png differ diff --git a/src/android/app/src/main/res/drawable-xhdpi/stick_c.png b/src/android/app/src/main/res/drawable-xhdpi/stick_c.png new file mode 100644 index 000000000..7819f220a Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/stick_c.png differ diff --git a/src/android/app/src/main/res/drawable-xhdpi/stick_c_pressed.png b/src/android/app/src/main/res/drawable-xhdpi/stick_c_pressed.png new file mode 100644 index 000000000..a111c2ac7 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/stick_c_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xhdpi/stick_c_range.png b/src/android/app/src/main/res/drawable-xhdpi/stick_c_range.png new file mode 100644 index 000000000..774c54292 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/stick_c_range.png differ diff --git a/src/android/app/src/main/res/drawable-xhdpi/stick_main.png b/src/android/app/src/main/res/drawable-xhdpi/stick_main.png new file mode 100644 index 000000000..3f80cdf6c Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/stick_main.png differ diff --git a/src/android/app/src/main/res/drawable-xhdpi/stick_main_pressed.png b/src/android/app/src/main/res/drawable-xhdpi/stick_main_pressed.png new file mode 100644 index 000000000..2a7675ef7 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/stick_main_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xhdpi/stick_main_range.png b/src/android/app/src/main/res/drawable-xhdpi/stick_main_range.png new file mode 100644 index 000000000..ca1672caf Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/stick_main_range.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/button_a.png b/src/android/app/src/main/res/drawable-xxhdpi/button_a.png new file mode 100644 index 000000000..999b4c01e Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/button_a.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/button_a_pressed.png b/src/android/app/src/main/res/drawable-xxhdpi/button_a_pressed.png new file mode 100644 index 000000000..bb4de9bd9 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/button_a_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/button_b.png b/src/android/app/src/main/res/drawable-xxhdpi/button_b.png new file mode 100644 index 000000000..8ed042e7e Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/button_b.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/button_b_pressed.png b/src/android/app/src/main/res/drawable-xxhdpi/button_b_pressed.png new file mode 100644 index 000000000..86f5d535e Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/button_b_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/button_l.png b/src/android/app/src/main/res/drawable-xxhdpi/button_l.png new file mode 100644 index 000000000..9572c66f8 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/button_l.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/button_l_pressed.png b/src/android/app/src/main/res/drawable-xxhdpi/button_l_pressed.png new file mode 100644 index 000000000..64bedc326 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/button_l_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/button_r.png b/src/android/app/src/main/res/drawable-xxhdpi/button_r.png new file mode 100644 index 000000000..abbcadede Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/button_r.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/button_r_pressed.png b/src/android/app/src/main/res/drawable-xxhdpi/button_r_pressed.png new file mode 100644 index 000000000..07421767f Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/button_r_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/button_select.png b/src/android/app/src/main/res/drawable-xxhdpi/button_select.png new file mode 100644 index 000000000..42c3b7c43 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/button_select.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/button_select_pressed.png b/src/android/app/src/main/res/drawable-xxhdpi/button_select_pressed.png new file mode 100644 index 000000000..0d1e56f6a Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/button_select_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/button_start.png b/src/android/app/src/main/res/drawable-xxhdpi/button_start.png new file mode 100644 index 000000000..4e9585bb4 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/button_start.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/button_start_pressed.png b/src/android/app/src/main/res/drawable-xxhdpi/button_start_pressed.png new file mode 100644 index 000000000..8c089e237 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/button_start_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/button_x.png b/src/android/app/src/main/res/drawable-xxhdpi/button_x.png new file mode 100644 index 000000000..0500f964f Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/button_x.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/button_x_pressed.png b/src/android/app/src/main/res/drawable-xxhdpi/button_x_pressed.png new file mode 100644 index 000000000..56db5843d Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/button_x_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/button_y.png b/src/android/app/src/main/res/drawable-xxhdpi/button_y.png new file mode 100644 index 000000000..53c5ca084 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/button_y.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/button_y_pressed.png b/src/android/app/src/main/res/drawable-xxhdpi/button_y_pressed.png new file mode 100644 index 000000000..5d91cbfb0 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/button_y_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/button_zl.png b/src/android/app/src/main/res/drawable-xxhdpi/button_zl.png new file mode 100644 index 000000000..f8ce9a0c6 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/button_zl.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/button_zl_pressed.png b/src/android/app/src/main/res/drawable-xxhdpi/button_zl_pressed.png new file mode 100644 index 000000000..981c8b0c8 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/button_zl_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/button_zr.png b/src/android/app/src/main/res/drawable-xxhdpi/button_zr.png new file mode 100644 index 000000000..82065e126 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/button_zr.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/button_zr_pressed.png b/src/android/app/src/main/res/drawable-xxhdpi/button_zr_pressed.png new file mode 100644 index 000000000..b30b2e799 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/button_zr_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/dpad.png b/src/android/app/src/main/res/drawable-xxhdpi/dpad.png new file mode 100644 index 000000000..36b7ea183 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/dpad.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/dpad_pressed_one_direction.png b/src/android/app/src/main/res/drawable-xxhdpi/dpad_pressed_one_direction.png new file mode 100644 index 000000000..3715e1c11 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/dpad_pressed_one_direction.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/dpad_pressed_two_directions.png b/src/android/app/src/main/res/drawable-xxhdpi/dpad_pressed_two_directions.png new file mode 100644 index 000000000..fb0d7fc5c Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/dpad_pressed_two_directions.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/ic_cia_install.png b/src/android/app/src/main/res/drawable-xxhdpi/ic_cia_install.png new file mode 100644 index 000000000..e6812f0d4 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/ic_cia_install.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/ic_folder.png b/src/android/app/src/main/res/drawable-xxhdpi/ic_folder.png new file mode 100644 index 000000000..05f429614 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/ic_folder.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/ic_premium.png b/src/android/app/src/main/res/drawable-xxhdpi/ic_premium.png new file mode 100644 index 000000000..63f162e52 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/ic_premium.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/ic_settings_core.png b/src/android/app/src/main/res/drawable-xxhdpi/ic_settings_core.png new file mode 100644 index 000000000..0b9049f46 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/ic_settings_core.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/ic_stat_notification_logo.png b/src/android/app/src/main/res/drawable-xxhdpi/ic_stat_notification_logo.png new file mode 100644 index 000000000..06cef9de3 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/ic_stat_notification_logo.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/stick_c.png b/src/android/app/src/main/res/drawable-xxhdpi/stick_c.png new file mode 100644 index 000000000..e950c5b15 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/stick_c.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/stick_c_pressed.png b/src/android/app/src/main/res/drawable-xxhdpi/stick_c_pressed.png new file mode 100644 index 000000000..3ac88ed9b Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/stick_c_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/stick_c_range.png b/src/android/app/src/main/res/drawable-xxhdpi/stick_c_range.png new file mode 100644 index 000000000..a3491c80f Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/stick_c_range.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/stick_main.png b/src/android/app/src/main/res/drawable-xxhdpi/stick_main.png new file mode 100644 index 000000000..16ca58c0f Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/stick_main.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/stick_main_pressed.png b/src/android/app/src/main/res/drawable-xxhdpi/stick_main_pressed.png new file mode 100644 index 000000000..e7fe0c2d5 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/stick_main_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/stick_main_range.png b/src/android/app/src/main/res/drawable-xxhdpi/stick_main_range.png new file mode 100644 index 000000000..8c47b2ba3 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/stick_main_range.png differ diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/button_a.png b/src/android/app/src/main/res/drawable-xxxhdpi/button_a.png new file mode 100644 index 000000000..e364fae1e Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/button_a.png differ diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/button_a_pressed.png b/src/android/app/src/main/res/drawable-xxxhdpi/button_a_pressed.png new file mode 100644 index 000000000..08d65cc99 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/button_a_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/button_b.png b/src/android/app/src/main/res/drawable-xxxhdpi/button_b.png new file mode 100644 index 000000000..faae9b6f7 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/button_b.png differ diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/button_b_pressed.png b/src/android/app/src/main/res/drawable-xxxhdpi/button_b_pressed.png new file mode 100644 index 000000000..669780f28 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/button_b_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/button_l.png b/src/android/app/src/main/res/drawable-xxxhdpi/button_l.png new file mode 100644 index 000000000..888b147de Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/button_l.png differ diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/button_l_pressed.png b/src/android/app/src/main/res/drawable-xxxhdpi/button_l_pressed.png new file mode 100644 index 000000000..605493e3e Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/button_l_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/button_r.png b/src/android/app/src/main/res/drawable-xxxhdpi/button_r.png new file mode 100644 index 000000000..90a93af8d Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/button_r.png differ diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/button_r_pressed.png b/src/android/app/src/main/res/drawable-xxxhdpi/button_r_pressed.png new file mode 100644 index 000000000..4500cd2be Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/button_r_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/button_select.png b/src/android/app/src/main/res/drawable-xxxhdpi/button_select.png new file mode 100644 index 000000000..b18b2fd59 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/button_select.png differ diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/button_select_pressed.png b/src/android/app/src/main/res/drawable-xxxhdpi/button_select_pressed.png new file mode 100644 index 000000000..53ed400e0 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/button_select_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/button_start.png b/src/android/app/src/main/res/drawable-xxxhdpi/button_start.png new file mode 100644 index 000000000..c55e56852 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/button_start.png differ diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/button_start_pressed.png b/src/android/app/src/main/res/drawable-xxxhdpi/button_start_pressed.png new file mode 100644 index 000000000..1507cc365 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/button_start_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/button_x.png b/src/android/app/src/main/res/drawable-xxxhdpi/button_x.png new file mode 100644 index 000000000..7ef2b883e Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/button_x.png differ diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/button_x_pressed.png b/src/android/app/src/main/res/drawable-xxxhdpi/button_x_pressed.png new file mode 100644 index 000000000..f3f11ede2 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/button_x_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/button_y.png b/src/android/app/src/main/res/drawable-xxxhdpi/button_y.png new file mode 100644 index 000000000..4ce679c69 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/button_y.png differ diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/button_y_pressed.png b/src/android/app/src/main/res/drawable-xxxhdpi/button_y_pressed.png new file mode 100644 index 000000000..926f5e269 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/button_y_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/button_zl.png b/src/android/app/src/main/res/drawable-xxxhdpi/button_zl.png new file mode 100644 index 000000000..7faf8db3b Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/button_zl.png differ diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/button_zl_pressed.png b/src/android/app/src/main/res/drawable-xxxhdpi/button_zl_pressed.png new file mode 100644 index 000000000..cc56a749c Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/button_zl_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/button_zr.png b/src/android/app/src/main/res/drawable-xxxhdpi/button_zr.png new file mode 100644 index 000000000..ed1b6b683 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/button_zr.png differ diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/button_zr_pressed.png b/src/android/app/src/main/res/drawable-xxxhdpi/button_zr_pressed.png new file mode 100644 index 000000000..892fa74f1 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/button_zr_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/dpad.png b/src/android/app/src/main/res/drawable-xxxhdpi/dpad.png new file mode 100644 index 000000000..6272f39e6 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/dpad.png differ diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/dpad_pressed_one_direction.png b/src/android/app/src/main/res/drawable-xxxhdpi/dpad_pressed_one_direction.png new file mode 100644 index 000000000..0cccd3a30 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/dpad_pressed_one_direction.png differ diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/dpad_pressed_two_directions.png b/src/android/app/src/main/res/drawable-xxxhdpi/dpad_pressed_two_directions.png new file mode 100644 index 000000000..18a99ad2d Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/dpad_pressed_two_directions.png differ diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/ic_cia_install.png b/src/android/app/src/main/res/drawable-xxxhdpi/ic_cia_install.png new file mode 100644 index 000000000..69ae32dc3 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/ic_cia_install.png differ diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/ic_folder.png b/src/android/app/src/main/res/drawable-xxxhdpi/ic_folder.png new file mode 100644 index 000000000..c85074c60 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/ic_folder.png differ diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/ic_premium.png b/src/android/app/src/main/res/drawable-xxxhdpi/ic_premium.png new file mode 100644 index 000000000..6f1550a10 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/ic_premium.png differ diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/ic_settings_core.png b/src/android/app/src/main/res/drawable-xxxhdpi/ic_settings_core.png new file mode 100644 index 000000000..2827a1777 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/ic_settings_core.png differ diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/stick_c.png b/src/android/app/src/main/res/drawable-xxxhdpi/stick_c.png new file mode 100644 index 000000000..88e09b8a0 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/stick_c.png differ diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/stick_c_pressed.png b/src/android/app/src/main/res/drawable-xxxhdpi/stick_c_pressed.png new file mode 100644 index 000000000..edc920e8e Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/stick_c_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/stick_c_range.png b/src/android/app/src/main/res/drawable-xxxhdpi/stick_c_range.png new file mode 100644 index 000000000..a8b693494 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/stick_c_range.png differ diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/stick_main.png b/src/android/app/src/main/res/drawable-xxxhdpi/stick_main.png new file mode 100644 index 000000000..d157edca2 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/stick_main.png differ diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/stick_main_pressed.png b/src/android/app/src/main/res/drawable-xxxhdpi/stick_main_pressed.png new file mode 100644 index 000000000..2ac2440be Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/stick_main_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/stick_main_range.png b/src/android/app/src/main/res/drawable-xxxhdpi/stick_main_range.png new file mode 100644 index 000000000..71e67e02a Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/stick_main_range.png differ diff --git a/src/android/app/src/main/res/drawable/gamelist_divider.xml b/src/android/app/src/main/res/drawable/gamelist_divider.xml new file mode 100644 index 000000000..7da9dccce --- /dev/null +++ b/src/android/app/src/main/res/drawable/gamelist_divider.xml @@ -0,0 +1,11 @@ + + + + + + + + diff --git a/src/android/app/src/main/res/drawable/ic_add.xml b/src/android/app/src/main/res/drawable/ic_add.xml deleted file mode 100644 index e3979cd7f..000000000 --- a/src/android/app/src/main/res/drawable/ic_add.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/src/android/app/src/main/res/drawable/ic_launcher_background.xml b/src/android/app/src/main/res/drawable/ic_launcher_background.xml deleted file mode 100644 index d5fccc538..000000000 --- a/src/android/app/src/main/res/drawable/ic_launcher_background.xml +++ /dev/null @@ -1,170 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/android/app/src/main/res/drawable/no_icon.png b/src/android/app/src/main/res/drawable/no_icon.png new file mode 100644 index 000000000..1ce8fdc76 Binary files /dev/null and b/src/android/app/src/main/res/drawable/no_icon.png differ diff --git a/src/android/app/src/main/res/layout/activity_emulation.xml b/src/android/app/src/main/res/layout/activity_emulation.xml new file mode 100644 index 000000000..7d7f36925 --- /dev/null +++ b/src/android/app/src/main/res/layout/activity_emulation.xml @@ -0,0 +1,17 @@ + + + + + + + \ No newline at end of file diff --git a/src/android/app/src/main/res/layout/activity_main.xml b/src/android/app/src/main/res/layout/activity_main.xml index d13b8e03e..cea0922a7 100644 --- a/src/android/app/src/main/res/layout/activity_main.xml +++ b/src/android/app/src/main/res/layout/activity_main.xml @@ -1,50 +1,27 @@ - + - + + + android:layout_height="wrap_content"> - + android:background="?colorPrimary"/> - + - - - - - - - + diff --git a/src/android/app/src/main/res/layout/activity_settings.xml b/src/android/app/src/main/res/layout/activity_settings.xml new file mode 100644 index 000000000..11b91c45f --- /dev/null +++ b/src/android/app/src/main/res/layout/activity_settings.xml @@ -0,0 +1,5 @@ + + diff --git a/src/android/app/src/main/res/layout/card_game.xml b/src/android/app/src/main/res/layout/card_game.xml new file mode 100644 index 000000000..6e87490f9 --- /dev/null +++ b/src/android/app/src/main/res/layout/card_game.xml @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + diff --git a/src/android/app/src/main/res/layout/dialog_checkbox.xml b/src/android/app/src/main/res/layout/dialog_checkbox.xml new file mode 100644 index 000000000..c0f307117 --- /dev/null +++ b/src/android/app/src/main/res/layout/dialog_checkbox.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/src/android/app/src/main/res/layout/dialog_progress_bar.xml b/src/android/app/src/main/res/layout/dialog_progress_bar.xml new file mode 100644 index 000000000..a81157a29 --- /dev/null +++ b/src/android/app/src/main/res/layout/dialog_progress_bar.xml @@ -0,0 +1,26 @@ + + + + + + + \ No newline at end of file diff --git a/src/android/app/src/main/res/layout/dialog_seekbar.xml b/src/android/app/src/main/res/layout/dialog_seekbar.xml new file mode 100644 index 000000000..35abecfcb --- /dev/null +++ b/src/android/app/src/main/res/layout/dialog_seekbar.xml @@ -0,0 +1,37 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/android/app/src/main/res/layout/filepicker_toolbar.xml b/src/android/app/src/main/res/layout/filepicker_toolbar.xml new file mode 100644 index 000000000..644934171 --- /dev/null +++ b/src/android/app/src/main/res/layout/filepicker_toolbar.xml @@ -0,0 +1,32 @@ + + + + + + + + + + diff --git a/src/android/app/src/main/res/layout/fragment_emulation.xml b/src/android/app/src/main/res/layout/fragment_emulation.xml new file mode 100644 index 000000000..d6e47e1e4 --- /dev/null +++ b/src/android/app/src/main/res/layout/fragment_emulation.xml @@ -0,0 +1,47 @@ + + + + + + + + + + +