Merge pull request #6004 from SachinVin/android-5-java-dump

Android dump
This commit is contained in:
bunnei 2022-04-21 23:00:42 -07:00 committed by GitHub
commit 4a9995ab9f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
372 changed files with 18175 additions and 859 deletions

12
.ci/android/build.sh Executable file
View file

@ -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

10
.ci/android/upload.sh Executable file
View file

@ -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"

View file

@ -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

View file

@ -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
# =======================================================================

View file

@ -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

View file

@ -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()

View file

@ -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

View file

@ -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
}

View file

@ -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());
}
}

View file

@ -1,39 +1,91 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="org.citra_emu.citra">
package="org.citra.citra_emu">
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false"/>
<uses-feature
android:name="android.hardware.gamepad"
android:required="false"/>
<uses-feature android:glEsVersion="0x00030001" />
<uses-feature android:glEsVersion="0x00030002" android:required="true" />
<uses-feature android:name="android.hardware.opengles.aep" android:required="true" />
<uses-feature
android:name="android.hardware.camera.any"
android:required="false" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<application
android:name="org.citra_emu.citra.CitraApplication"
android:label="Citra"
android:icon="@mipmap/ic_citra"
android:allowBackup="true"
android:name="org.citra.citra_emu.CitraApplication"
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:allowBackup="false"
android:supportsRtl="true"
android:isGame="true"
android:banner="@mipmap/ic_citra">
android:banner="@mipmap/ic_launcher"
android:requestLegacyExternalStorage="true">
<activity
android:name=".ui.main.MainActivity"
android:theme="@style/CitraBase">
android:name="org.citra.citra_emu.ui.main.MainActivity"
android:theme="@style/CitraBase"
android:resizeableActivity="false">
<!-- This intentfilter marks this Activity as the one that gets launched from Home screen. -->
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<activity
android:name="org.citra.citra_emu.features.settings.ui.SettingsActivity"
android:configChanges="orientation|screenSize|uiMode"
android:theme="@style/CitraSettingsBase"
android:label="@string/preferences_settings"/>
<activity
android:name="org.citra.citra_emu.activities.EmulationActivity"
android:resizeableActivity="false"
android:theme="@style/CitraEmulationBase"
android:launchMode="singleTop"/>
<service android:name="org.citra.citra_emu.utils.ForegroundService"/>
<activity
android:name="org.citra.citra_emu.activities.CustomFilePickerActivity"
android:label="@string/app_name"
android:theme="@style/FilePickerTheme">
<intent-filter>
<action android:name="android.intent.action.GET_CONTENT" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<service android:name="org.citra.citra_emu.utils.DirectoryInitialization"/>
<provider
android:name="org.citra.citra_emu.model.GameProvider"
android:authorities="${applicationId}.provider"
android:enabled="true"
android:exported="false">
</provider>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.filesprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/nnf_provider_paths" />
</provider>
</application>
</manifest>

View file

@ -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 "../../../../../" "./")

View file

@ -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>(level), GetJString(env, file_name).data(),
static_cast<unsigned int>(line_number), GetJString(env, function).data(),
GetJString(env, msg).data());
}
}
} // namespace Log

View file

@ -1,38 +0,0 @@
// Copyright 2019 Citra Emulator Project
// Licensed under GPLv2 or any later version
// Refer to the license.txt file included.
#include <android/log.h>
#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

View file

@ -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

View file

@ -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

View file

@ -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 <string>
#include <jni.h>
namespace CitraJNI {
extern "C" {
jint JNI_OnLoad(JavaVM* vm, void* reserved);
}
std::string GetJString(JNIEnv* env, jstring jstr);
} // namespace CitraJNI

View file

@ -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::FileBackend>(log_dir + LOG_FILE));
Log::AddBackend(std::make_unique<Log::LogcatBackend>());
}
};
}; // namespace MainActivity

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

View file

@ -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();
}
}

View file

@ -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<EmulationActivity> 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 <a href=\"https://citra-emu.org/wiki/dumping-game-cartridges/\">game cartidges</a> or <a href=\"https://citra-emu.org/wiki/dumping-installed-titles/\">installed titles</a>.", 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;
}
}

View file

@ -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<File> 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;
}
}

View file

@ -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<InputDevice.MotionRange> 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 {
}
}

View file

@ -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<GameViewHolder> 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();
}
}
}

View file

@ -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<String> 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;
}
}

View file

@ -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);
}

View file

@ -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);
}
}

View file

@ -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<Float> 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<InputDevice.MotionRange> 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;
}
}

View file

@ -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;
}
}
}

View file

@ -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";
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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();
}

View file

@ -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<String, Setting> 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<String, Setting> getSettings() {
return mSettings;
}
public void mergeSection(SettingSection settingSection) {
for (Setting setting : settingSection.mSettings.values()) {
putSetting(setting);
}
}
}

View file

@ -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<String, List<String>> 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<String, SettingSection> 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<String, SettingSection> {
@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<String, SettingSection> sections = new Settings.SettingsSectionMap();
public SettingSection getSection(String sectionName) {
return sections.get(sectionName);
}
public boolean isEmpty() {
return sections.isEmpty();
}
public HashMap<String, SettingSection> 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<String, List<String>> 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<String, SettingSection> updatedSections) {
for (Map.Entry<String, SettingSection> 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<String, List<String>> entry : configFileSectionsMap.entrySet()) {
String fileName = entry.getKey();
List<String> sectionNames = entry.getValue();
TreeMap<String, SettingSection> 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);
}
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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();
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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);
}

View file

@ -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<SettingViewHolder>
implements DialogInterface.OnClickListener, SeekBar.OnSeekBarChangeListener {
private SettingsFragmentView mView;
private Context mContext;
private ArrayList<SettingsItem> 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<SettingsItem> 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;
}
}

View file

@ -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<SettingsItem> 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();
}
}

View file

@ -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<SettingsItem> 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<SettingsItem> 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<SettingsItem> 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<SettingsItem> 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<SettingsItem> 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<SettingsItem> 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<SettingsItem> 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<String> supportedCameraNameList = new ArrayList<>();
ArrayList<String> 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<String> cameraDeviceNameList = new ArrayList<>(Arrays.asList(activity.getResources().getStringArray(R.array.cameraDeviceNames)));
cameraDeviceNameList.addAll(supportedCameraNameList);
ArrayList<String> 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<SettingsItem> 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<SettingsItem> 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<SettingsItem> 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<SettingsItem> 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));
}
}

View file

@ -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<SettingsItem> 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();
}

View file

@ -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);
}
}

View file

@ -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());
}
}

View file

@ -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());
}
}

View file

@ -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
}
}

View file

@ -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());
}
}

View file

@ -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);
}
}
}

View file

@ -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);
}

View file

@ -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);
}
}
}

View file

@ -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());
}
}

View file

@ -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);
}
}

View file

@ -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<String, String> 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<String, SettingSection> readFile(final File ini, boolean isCustomGame, SettingsActivityView view) {
HashMap<String, SettingSection> 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<String, SettingSection> 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<String, SettingSection> 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<String, SettingSection> sections,
SettingsActivityView view) {
File ini = getSettingsFile(fileName);
try {
Wini writer = new Wini(ini);
Set<String> 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<String, SettingSection> sections) {
Set<String> sortedSections = new TreeSet<>(sections.keySet());
for (String sectionKey : sortedSections) {
SettingSection section = sections.get(sectionKey);
HashMap<String, Setting> settings = section.getSettings();
Set<String> 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<String, Setting> settings = section.getSettings();
Set<String> keySet = settings.keySet();
for (String key : keySet) {
Setting setting = settings.get(key);
parser.put(header, setting.getKey(), setting.getValueAsString());
}
}
}

View file

@ -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<String> 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);
}
}

View file

@ -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
}
}
}

View file

@ -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;
}
}

View file

@ -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<String> allowedExtensions = new HashSet<String>(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<String> 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<String> 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<Cursor> 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);
}
}

View file

@ -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;
}
}

View file

@ -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<InputOverlayDrawableButton> overlayButtons = new HashSet<>();
private final Set<InputOverlayDrawableDpad> overlayDpads = new HashSet<>();
private final Set<InputOverlayDrawableJoystick> 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.
* <p>
* This works due to the way the X and Y coordinates are stored within
* the {@link SharedPreferences}.
* <p>
* 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.
* <p>
* This has a few benefits over the conventional way of storing the values
* (ie. within the Citra ini file).
* <ul>
* <li>No native calls</li>
* <li>Keeps Android-only values inside the Android environment</li>
* </ul>
* <p>
* 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;
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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.");
}
}
}

View file

@ -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);
}
}

View file

@ -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();
}
}

View file

@ -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);
}

View file

@ -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);
}
}

View file

@ -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);
});
}
}

View file

@ -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);
}

View file

@ -0,0 +1,5 @@
package org.citra.citra_emu.utils;
public interface Action1<T> {
void call(T t);
}

View file

@ -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();
}
}

View file

@ -0,0 +1,22 @@
package org.citra.citra_emu.utils;
import java.util.HashMap;
import java.util.Map;
public class BiMap<K, V> {
private Map<K, V> forward = new HashMap<K, V>();
private Map<V, K> backward = new HashMap<V, K>();
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);
}
}

View file

@ -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<Purchase> 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<SkuDetails> 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<String> 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);
}
}
}

View file

@ -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;
}
}

View file

@ -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
}
}

View file

@ -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<DirectoryInitializationState> callback;
public DirectoryStateReceiver(Action1<DirectoryInitializationState> callback) {
this.callback = callback;
}
@Override
public void onReceive(Context context, Intent intent) {
DirectoryInitializationState state = (DirectoryInitializationState) intent
.getSerializableExtra(DirectoryInitialization.EXTRA_STATE);
callback.call(state);
}
}

View file

@ -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();
}
}

View file

@ -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<String> 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<String> 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<Uri> 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<Uri> 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;
}
}

View file

@ -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;
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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;
}
}

View file

@ -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";
}
}

View file

@ -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;
}
}
}

View file

@ -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();
}
}
}

View file

@ -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));
}
}

View file

@ -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;
}
}

View file

@ -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");
}
}

Some files were not shown because too many files have changed in this diff Show more