1
0
mirror of https://github.com/vcmi/vcmi.git synced 2024-12-24 22:14:36 +02:00

add existing android files

This commit is contained in:
Andrey Filipenkov 2023-02-16 11:11:39 +03:00
parent cc966902cd
commit 7d4f8ab70d
119 changed files with 12636 additions and 0 deletions

9
android/.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
*.iml
.gradle
/local.properties
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties

View File

@ -0,0 +1,9 @@
package eu.vcmi.vcmi.util;
/**
* Generated via cmake (./project/vcmi-app/cmake-scripts/versions.cmake)
*/
public class GeneratedVersion
{
public static final String VCMI_VERSION = "@VCMI_VERSION@";
}

21
android/build.gradle Normal file
View File

@ -0,0 +1,21 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:7.3.0'
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}
allprojects {
apply from: rootProject.file("defs.gradle")
}
task clean(type: Delete) {
delete rootProject.buildDir
}

27
android/defs.gradle Normal file
View File

@ -0,0 +1,27 @@
import groovy.json.JsonSlurper
ext {
final def jsonFile = rootProject.file("../vcmiconf.json")
final def rawConf = new JsonSlurper().parseText(jsonFile.text)
PROJECT_PATH_BASE = jsonFile.getParentFile().getAbsolutePath().replace('\\', '/')
VCMI_PATH_EXT = "${PROJECT_PATH_BASE}/ext"
VCMI_PATH_MAIN = "${PROJECT_PATH_BASE}/project"
VCMI_PATH_VCMI = "${VCMI_PATH_EXT}/vcmi"
// can be 16 if building only for armeabi-v7a, definitely needs to be 21+ to build arm64 and x86_64
VCMI_PLATFORM = rawConf.androidApi
// we should be able to use the newest version to compile, but it seems that gradle-experimental is somehow broken and doesn't compile native libs correctly for apis older than this setting...
VCMI_COMPILE_SDK = 26
VCMI_ABIS = rawConf.abis.split(" ")
VCMI_STL_VERSION = "c++_shared"
VCMI_BUILD_TOOLS = "25.0.2"
// these values will be retrieved during gradle build
gitInfoLauncher = "none"
gitInfoVcmi = "none"
//logger.info("Base path = ${PROJECT_PATH_BASE}")
}

View File

19
android/gradle.properties Normal file
View File

@ -0,0 +1,19 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app"s APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Automatically convert third-party libraries to use AndroidX
android.enableJetifier=true

Binary file not shown.

View File

@ -0,0 +1,6 @@
#Tue Sep 27 21:00:27 EEST 2022
distributionBase=GRADLE_USER_HOME
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip
distributionPath=wrapper/dists
zipStorePath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME

185
android/gradlew vendored Executable file
View File

@ -0,0 +1,185 @@
#!/usr/bin/env sh
#
# Copyright 2015 the original author or authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=`expr $i + 1`
done
case $i in
0) set -- ;;
1) set -- "$args0" ;;
2) set -- "$args0" "$args1" ;;
3) set -- "$args0" "$args1" "$args2" ;;
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=`save "$@"`
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
exec "$JAVACMD" "$@"

89
android/gradlew.bat vendored Normal file
View File

@ -0,0 +1,89 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

9
android/settings.gradle Normal file
View File

@ -0,0 +1,9 @@
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "VCMI"
include ':vcmi-app'

1
android/vcmi-app/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

View File

@ -0,0 +1,214 @@
plugins {
id 'com.android.application'
}
android {
compileSdk 31
defaultConfig {
applicationId "is.xyz.vcmi"
minSdk 19
targetSdk 31
versionCode 1103
versionName "1.1"
setProperty("archivesBaseName", "vcmi")
externalNativeBuild {
cmake {
version "3.18+"
arguments "-DANDROID_STL=${VCMI_STL_VERSION}",
"-DANDROID_NATIVE_API_LEVEL=${VCMI_PLATFORM}",
"-DANDROID_TOOLCHAIN=clang",
"-DVCMI_ROOT=${PROJECT_PATH_BASE}"
cppFlags "-frtti", "-fexceptions", "-Wno-switch"
}
}
ndk {
abiFilters = new HashSet<>()
abiFilters.addAll(VCMI_ABIS)
}
}
signingConfigs {
releaseSigning
LoadSigningConfig(PROJECT_PATH_BASE)
}
sourceSets {
main {
jniLibs.srcDirs = ["${PROJECT_PATH_BASE}/ext-output"]
}
}
buildTypes {
release {
minifyEnabled false
zipAlignEnabled true
signingConfig signingConfigs.releaseSigning
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
applicationVariants.all { variant -> RenameOutput(project.archivesBaseName, variant) }
tasks.withType(JavaCompile) {
options.compilerArgs += ["-Xlint:deprecation"]
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
externalNativeBuild {
cmake {
version "3.18.0+"
path file('cmake-scripts/CMakeLists.txt')
}
}
buildFeatures {
viewBinding true
dataBinding true
}
flavorDimensions "vcmi"
productFlavors {
VcmiOnly {
dimension "vcmi"
externalNativeBuild {
cmake {
version "3.18+"
targets "vcmi",
"vcmiserver",
"vcmiclient"
}
}
}
LibsOnly {
dimension "vcmi"
externalNativeBuild {
cmake {
version "3.18+"
targets "boost-datetime",
"boost-system",
"boost-filesystem",
"boost-locale",
"boost-program-options",
"boost-thread",
"fl-shared",
"minizip"
}
}
}
AllTargets {
dimension "vcmi"
externalNativeBuild {
cmake {
version "3.18+"
targets "boost-datetime",
"boost-system",
"boost-filesystem",
"boost-locale",
"boost-program-options",
"boost-thread",
"fl-shared",
"minizip",
"vcmi",
"vcmiserver",
"vcmiclient"
}
}
}
}
}
def RenameOutput(final baseName, final variant) {
final def buildTaskId = System.getenv("GITHUB_RUN_ID")
ResolveGitInfo()
def name = baseName + "-" + ext.gitInfoLauncher + "-" + ext.gitInfoVcmi
if (buildTaskId != null && !buildTaskId.isEmpty()) {
name = buildTaskId + "-" + name
}
if (!variant.buildType.name != "release") {
name += "-" + variant.buildType.name
}
variant.outputs.each { output ->
def oldPath = output.outputFile.getAbsolutePath()
output.outputFileName = name + oldPath.substring(oldPath.lastIndexOf("."))
}
}
def CommandOutput(final cmd, final arguments, final cwd) {
try {
new ByteArrayOutputStream().withStream { final os ->
exec {
executable cmd
args arguments
workingDir cwd
standardOutput os
}
return os.toString().trim()
}
}
catch (final Exception ex) {
print("Broken: " + cmd + " " + arguments + " in " + cwd + " :: " + ex.toString())
return ""
}
}
def ResolveGitInfo() {
if (ext.gitInfoLauncher != "none" && ext.gitInfoVcmi != "none") {
return
}
ext.gitInfoLauncher = CommandOutput("git", ["describe", "--match=", "--always", "--abbrev=7"], PROJECT_PATH_BASE)
ext.gitInfoVcmi =
CommandOutput("git", ["log", "-1", "--pretty=%D", "--decorate-refs=refs/remotes/origin/*"], PROJECT_PATH_BASE + "/ext/vcmi").replace("origin/", "").replace(", HEAD", "").replaceAll("[^a-zA-Z0-9\\-_]", "_") +
"-" +
CommandOutput("git", ["describe", "--match=", "--always", "--abbrev=7"], PROJECT_PATH_BASE + "/ext/vcmi")
}
def SigningPropertiesPath(final basePath) {
return file(basePath + "/.github/CI/signing.properties")
}
def SigningKeystorePath(final basePath, final keystoreFileName) {
return file(basePath + "/.github/CI/" + keystoreFileName)
}
def LoadSigningConfig(final basePath) {
final def props = new Properties()
final def propFile = SigningPropertiesPath(basePath)
if (propFile.canRead()) {
props.load(new FileInputStream(propFile))
if (props != null
&& props.containsKey('STORE_FILE')
&& props.containsKey('STORE_PASSWORD')
&& props.containsKey('KEY_ALIAS')
&& props.containsKey('KEY_PASSWORD')) {
android.signingConfigs.releaseSigning.storeFile = SigningKeystorePath(basePath, props['STORE_FILE'])
android.signingConfigs.releaseSigning.storePassword = props['STORE_PASSWORD']
android.signingConfigs.releaseSigning.keyAlias = props['KEY_ALIAS']
android.signingConfigs.releaseSigning.keyPassword = props['KEY_PASSWORD']
} else {
println("Some props from signing file are missing")
android.buildTypes.release.signingConfig = null
}
} else {
println("file with signing properties is missing")
android.buildTypes.release.signingConfig = null
}
}
dependencies {
implementation 'androidx.appcompat:appcompat:1.2.0'
implementation 'com.google.android.material:material:1.3.0'
implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
}

21
android/vcmi-app/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="eu.vcmi.vcmi">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:extractNativeLibs="true"
android:hardwareAccelerated="true"
android:hasFragileUserData="true"
android:allowBackup="false"
android:installLocation="auto"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:testOnly="false"
android:supportsRtl="true"
android:theme="@style/Theme.VCMI">
<activity
android:exported="true"
android:name=".ActivityLauncher"
android:screenOrientation="sensorLandscape">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".ActivityError"
android:screenOrientation="sensorLandscape" />
<activity
android:name=".ActivityMods"
android:screenOrientation="sensorLandscape" />
<activity
android:name=".ActivityAbout"
android:screenOrientation="sensorLandscape" />
<activity
android:name=".VcmiSDLActivity"
android:configChanges="keyboardHidden|orientation|screenSize"
android:label="@string/app_name"
android:launchMode="singleTop"
android:screenOrientation="sensorLandscape"
android:theme="@style/Theme.VCMI.Full" />
<service
android:name=".ServerService"
android:process="eu.vcmi.vcmi.srv"
android:description="@string/server_name"
android:exported="false"/>
</application>
</manifest>

View File

@ -0,0 +1,105 @@
package eu.vcmi.vcmi;
import android.content.ActivityNotFoundException;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import android.text.SpannableString;
import android.text.Spanned;
import android.text.style.ForegroundColorSpan;
import android.text.style.UnderlineSpan;
import android.view.View;
import android.widget.TextView;
import android.widget.Toast;
import eu.vcmi.vcmi.content.DialogAuthors;
import eu.vcmi.vcmi.util.GeneratedVersion;
import eu.vcmi.vcmi.util.Utils;
/**
* @author F
*/
public class ActivityAbout extends ActivityWithToolbar
{
private static final String DIALOG_AUTHORS_TAG = "DIALOG_AUTHORS_TAG";
@Override
protected void onCreate(@Nullable final Bundle savedInstanceState)
{
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_about);
initToolbar(R.string.about_title);
initControl(R.id.about_version_app, getString(R.string.about_version_app, GeneratedVersion.VCMI_VERSION));
initControl(R.id.about_version_launcher, getString(R.string.about_version_launcher, Utils.appVersionName(this)));
initControlUrl(R.id.about_link_portal, R.string.about_links_main, R.string.url_project_page, this::onUrlPressed);
initControlUrl(R.id.about_link_repo_main, R.string.about_links_repo, R.string.url_project_repo, this::onUrlPressed);
initControlUrl(R.id.about_link_repo_launcher, R.string.about_links_repo_launcher, R.string.url_launcher_repo, this::onUrlPressed);
initControlBtn(R.id.about_btn_authors, this::onBtnAuthorsPressed);
initControlBtn(R.id.about_btn_libs, this::onBtnLibsPressed);
initControlUrl(R.id.about_btn_privacy, R.string.about_btn_privacy, R.string.url_launcher_privacy, this::onUrlPressed);
}
private void initControlBtn(final int viewId, final View.OnClickListener callback)
{
findViewById(viewId).setOnClickListener(callback);
}
private void initControlUrl(final int textViewResId, final int baseTextRes, final int urlTextRes, final IInternalUrlCallback callback)
{
final TextView ctrl = (TextView) findViewById(textViewResId);
final String urlText = getString(urlTextRes);
final String fullText = getString(baseTextRes, urlText);
ctrl.setText(decoratedLinkText(fullText, fullText.indexOf(urlText), fullText.length()));
ctrl.setOnClickListener(v -> callback.onPressed(urlText));
}
private Spanned decoratedLinkText(final String rawText, final int start, final int end)
{
final SpannableString spannableString = new SpannableString(rawText);
spannableString.setSpan(new UnderlineSpan(), start, end, 0);
spannableString.setSpan(new ForegroundColorSpan(ContextCompat.getColor(this, R.color.accent)), start, end, 0);
return spannableString;
}
private void initControl(final int textViewResId, final String text)
{
((TextView) findViewById(textViewResId)).setText(text);
}
private void onBtnAuthorsPressed(final View v)
{
final DialogAuthors dialogAuthors = new DialogAuthors();
dialogAuthors.show(getSupportFragmentManager(), DIALOG_AUTHORS_TAG);
}
private void onBtnLibsPressed(final View v)
{
// TODO 3rd party libs view (dialog?)
}
private void onBtnPrivacyPressed(final View v)
{
// TODO tbd if we even need this in app
}
private void onUrlPressed(final String url)
{
try
{
startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(url)));
}
catch (final ActivityNotFoundException ignored)
{
Toast.makeText(this, R.string.about_error_opening_url, Toast.LENGTH_LONG).show();
}
}
private interface IInternalUrlCallback
{
void onPressed(final String link);
}
}

View File

@ -0,0 +1,58 @@
package eu.vcmi.vcmi;
import android.os.Bundle;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import eu.vcmi.vcmi.util.Log;
import eu.vcmi.vcmi.util.SharedPrefs;
/**
* @author F
*/
public abstract class ActivityBase extends AppCompatActivity
{
protected SharedPrefs mPrefs;
@Override
protected void onCreate(@Nullable final Bundle savedInstanceState)
{
super.onCreate(savedInstanceState);
setupExceptionHandler();
mPrefs = new SharedPrefs(this);
}
private void setupExceptionHandler()
{
final Thread.UncaughtExceptionHandler prevHandler = Thread.getDefaultUncaughtExceptionHandler();
if (prevHandler != null && !(prevHandler instanceof VCMIExceptionHandler)) // no need to recreate it if it's already setup
{
Thread.setDefaultUncaughtExceptionHandler(new VCMIExceptionHandler(prevHandler));
}
}
private static class VCMIExceptionHandler implements Thread.UncaughtExceptionHandler
{
private Thread.UncaughtExceptionHandler mPrevHandler;
private VCMIExceptionHandler(final Thread.UncaughtExceptionHandler prevHandler)
{
mPrevHandler = prevHandler;
}
@Override
public void uncaughtException(final Thread thread, final Throwable throwable)
{
Log.e(this, "Unhandled exception", throwable); // to save the exception to file before crashing
if (mPrevHandler != null && !(mPrevHandler instanceof VCMIExceptionHandler))
{
mPrevHandler.uncaughtException(thread, throwable);
}
else
{
System.exit(1);
}
}
}
}

View File

@ -0,0 +1,48 @@
package eu.vcmi.vcmi;
import android.content.Intent;
import android.os.Bundle;
import androidx.annotation.Nullable;
import android.view.View;
import android.widget.TextView;
/**
* @author F
*/
public class ActivityError extends ActivityWithToolbar
{
public static final String ARG_ERROR_MSG = "ActivityError.msg";
@Override
protected void onCreate(@Nullable final Bundle savedInstanceState)
{
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_error);
initToolbar(R.string.launcher_title);
final View btnTryAgain = findViewById(R.id.error_btn_try_again);
btnTryAgain.setOnClickListener(new OnErrorRetryPressed());
final Bundle extras = getIntent().getExtras();
if (extras != null)
{
final String errorMessage = extras.getString(ARG_ERROR_MSG);
final TextView errorMessageView = (TextView) findViewById(R.id.error_message);
if (errorMessage != null)
{
errorMessageView.setText(errorMessage);
}
}
}
private class OnErrorRetryPressed implements View.OnClickListener
{
@Override
public void onClick(final View v)
{
// basically restarts main activity
startActivity(new Intent(ActivityError.this, ActivityLauncher.class));
finish();
}
}
}

View File

@ -0,0 +1,303 @@
package eu.vcmi.vcmi;
import android.Manifest;
import android.app.Activity;
import android.content.Intent;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.widget.TextView;
import android.widget.Toast;
import org.json.JSONObject;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
import eu.vcmi.vcmi.content.AsyncLauncherInitialization;
import eu.vcmi.vcmi.settings.AdventureAiController;
import eu.vcmi.vcmi.settings.CodepageSettingController;
import eu.vcmi.vcmi.settings.CopyDataController;
import eu.vcmi.vcmi.settings.ExportDataController;
import eu.vcmi.vcmi.settings.LauncherSettingController;
import eu.vcmi.vcmi.settings.ModsBtnController;
import eu.vcmi.vcmi.settings.MusicSettingController;
import eu.vcmi.vcmi.settings.PointerModeSettingController;
import eu.vcmi.vcmi.settings.PointerMultiplierSettingController;
import eu.vcmi.vcmi.settings.ScreenResSettingController;
import eu.vcmi.vcmi.settings.SoundSettingController;
import eu.vcmi.vcmi.settings.StartGameController;
import eu.vcmi.vcmi.util.FileUtil;
import eu.vcmi.vcmi.util.Log;
import eu.vcmi.vcmi.util.SharedPrefs;
/**
* @author F
*/
public class ActivityLauncher extends ActivityWithToolbar
{
public static final int PERMISSIONS_REQ_CODE = 123;
private final List<LauncherSettingController<?, ?>> mActualSettings = new ArrayList<>();
private View mProgress;
private TextView mErrorMessage;
private Config mConfig;
private LauncherSettingController<ScreenResSettingController.ScreenRes, Config> mCtrlScreenRes;
private LauncherSettingController<String, Config> mCtrlCodepage;
private LauncherSettingController<PointerModeSettingController.PointerMode, Config> mCtrlPointerMode;
private LauncherSettingController<Void, Void> mCtrlStart;
private LauncherSettingController<Float, Config> mCtrlPointerMulti;
private LauncherSettingController<Integer, Config> mCtrlSoundVol;
private LauncherSettingController<Integer, Config> mCtrlMusicVol;
private LauncherSettingController<String, Config> mAiController;
private CopyDataController mCtrlCopy;
private ExportDataController mCtrlExport;
private final AsyncLauncherInitialization.ILauncherCallbacks mInitCallbacks = new AsyncLauncherInitialization.ILauncherCallbacks()
{
@Override
public Activity ctx()
{
return ActivityLauncher.this;
}
@Override
public SharedPrefs prefs()
{
return mPrefs;
}
@Override
public void onInitSuccess()
{
loadConfigFile();
mCtrlStart.show();
mCtrlCopy.show();
mCtrlExport.show();
for (LauncherSettingController<?, ?> setting: mActualSettings) {
setting.show();
}
mErrorMessage.setVisibility(View.GONE);
mProgress.setVisibility(View.GONE);
}
@Override
public void onInitFailure(final AsyncLauncherInitialization.InitResult result)
{
mCtrlCopy.show();
if (result.mFailSilently)
{
return;
}
ActivityLauncher.this.onInitFailure(result);
}
};
@Override
public void onCreate(final Bundle savedInstanceState)
{
super.onCreate(savedInstanceState);
if (savedInstanceState == null) // only clear the log if this is initial onCreate and not config change
{
Log.init();
}
Log.i(this, "Starting launcher");
setContentView(R.layout.activity_launcher);
initToolbar(R.string.launcher_title, true);
mProgress = findViewById(R.id.launcher_progress);
mErrorMessage = (TextView) findViewById(R.id.launcher_error);
mErrorMessage.setVisibility(View.GONE);
((TextView) findViewById(R.id.launcher_version_info)).setText(getString(R.string.launcher_version, BuildConfig.VERSION_NAME));
initSettingsGui();
}
@Override
public void onStart()
{
super.onStart();
new AsyncLauncherInitialization(mInitCallbacks).execute((Void) null);
}
@Override
public void onBackPressed()
{
saveConfig();
super.onBackPressed();
}
@Override
public boolean onCreateOptionsMenu(final Menu menu)
{
getMenuInflater().inflate(R.menu.menu_launcher, menu);
return true;
}
@Override
public boolean onOptionsItemSelected(final MenuItem item)
{
if (item.getItemId() == R.id.menu_launcher_about)
{
startActivity(new Intent(this, ActivityAbout.class));
return true;
}
return super.onOptionsItemSelected(item);
}
@Override
public void onActivityResult(int requestCode, int resultCode, Intent resultData)
{
if(requestCode == CopyDataController.PICK_EXTERNAL_VCMI_DATA_TO_COPY
&& resultCode == Activity.RESULT_OK)
{
Uri uri;
if (resultData != null)
{
uri = resultData.getData();
mCtrlCopy.copyData(uri);
}
return;
}
if(requestCode == ExportDataController.PICK_DIRECTORY_TO_EXPORT
&& resultCode == Activity.RESULT_OK)
{
Uri uri = null;
if (resultData != null)
{
uri = resultData.getData();
mCtrlExport.copyData(uri);
}
return;
}
super.onActivityResult(requestCode, resultCode, resultData);
}
public void requestStoragePermissions()
{
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
{
requestPermissions(
new String[] {Manifest.permission.WRITE_EXTERNAL_STORAGE},
PERMISSIONS_REQ_CODE);
}
}
private void initSettingsGui()
{
mCtrlStart = new StartGameController(this, v -> onLaunchGameBtnPressed()).init(R.id.launcher_btn_start);
(mCtrlCopy = new CopyDataController(this)).init(R.id.launcher_btn_copy);
(mCtrlExport = new ExportDataController(this)).init(R.id.launcher_btn_export);
new ModsBtnController(this, v -> startActivity(new Intent(ActivityLauncher.this, ActivityMods.class))).init(R.id.launcher_btn_mods);
mCtrlScreenRes = new ScreenResSettingController(this).init(R.id.launcher_btn_res, mConfig);
mCtrlCodepage = new CodepageSettingController(this).init(R.id.launcher_btn_cp, mConfig);
mCtrlPointerMode = new PointerModeSettingController(this).init(R.id.launcher_btn_pointer_mode, mConfig);
mCtrlPointerMulti = new PointerMultiplierSettingController(this).init(R.id.launcher_btn_pointer_multi, mConfig);
mCtrlSoundVol = new SoundSettingController(this).init(R.id.launcher_btn_volume_sound, mConfig);
mCtrlMusicVol = new MusicSettingController(this).init(R.id.launcher_btn_volume_music, mConfig);
mAiController = new AdventureAiController(this).init(R.id.launcher_btn_adventure_ai, mConfig);
mActualSettings.clear();
mActualSettings.add(mCtrlCodepage);
mActualSettings.add(mCtrlScreenRes);
mActualSettings.add(mCtrlPointerMode);
mActualSettings.add(mCtrlPointerMulti);
mActualSettings.add(mCtrlSoundVol);
mActualSettings.add(mCtrlMusicVol);
mActualSettings.add(mAiController);
mCtrlStart.hide(); // start is initially hidden, until we confirm that everything is okay via AsyncLauncherInitialization
mCtrlCopy.hide();
mCtrlExport.hide();
}
private void onLaunchGameBtnPressed()
{
saveConfig();
startActivity(new Intent(ActivityLauncher.this, VcmiSDLActivity.class));
}
private void saveConfig()
{
if (mConfig == null)
{
return;
}
try
{
mConfig.save(new File(FileUtil.configFileLocation(Storage.getVcmiDataDir(this))));
}
catch (final Exception e)
{
Toast.makeText(this, getString(R.string.launcher_error_config_saving_failed, e.getMessage()), Toast.LENGTH_LONG).show();
}
}
private void loadConfigFile()
{
try
{
final String settingsFileContent = FileUtil.read(
new File(FileUtil.configFileLocation(Storage.getVcmiDataDir(this))));
mConfig = Config.load(new JSONObject(settingsFileContent));
}
catch (final Exception e)
{
Log.e(this, "Could not load config file", e);
mConfig = new Config();
}
onConfigUpdated();
}
private void onConfigUpdated()
{
updateCtrlConfig(mCtrlScreenRes, mConfig);
updateCtrlConfig(mCtrlCodepage, mConfig);
updateCtrlConfig(mCtrlPointerMode, mConfig);
updateCtrlConfig(mCtrlPointerMulti, mConfig);
updateCtrlConfig(mCtrlSoundVol, mConfig);
updateCtrlConfig(mCtrlMusicVol, mConfig);
updateCtrlConfig(mAiController, mConfig);
}
private <TSetting, TConf> void updateCtrlConfig(
final LauncherSettingController<TSetting, TConf> ctrl,
final TConf config)
{
if (ctrl != null)
{
ctrl.updateConfig(config);
}
}
private void onInitFailure(final AsyncLauncherInitialization.InitResult initResult)
{
Log.d(this, "Init failed with " + initResult);
mProgress.setVisibility(View.GONE);
mCtrlStart.hide();
for (LauncherSettingController<?, ?> setting: mActualSettings)
{
setting.hide();
}
mErrorMessage.setVisibility(View.VISIBLE);
mErrorMessage.setText(initResult.mMessage);
}
}

View File

@ -0,0 +1,331 @@
package eu.vcmi.vcmi;
import android.content.DialogInterface;
import android.os.AsyncTask;
import android.os.Bundle;
import androidx.annotation.Nullable;
import com.google.android.material.snackbar.Snackbar;
import androidx.appcompat.app.AlertDialog;
import androidx.recyclerview.widget.DefaultItemAnimator;
import androidx.recyclerview.widget.DividerItemDecoration;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.widget.TextView;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import eu.vcmi.vcmi.content.ModBaseViewHolder;
import eu.vcmi.vcmi.content.ModsAdapter;
import eu.vcmi.vcmi.mods.VCMIMod;
import eu.vcmi.vcmi.mods.VCMIModContainer;
import eu.vcmi.vcmi.mods.VCMIModsRepo;
import eu.vcmi.vcmi.util.InstallModAsync;
import eu.vcmi.vcmi.util.FileUtil;
import eu.vcmi.vcmi.util.Log;
import eu.vcmi.vcmi.util.ServerResponse;
/**
* @author F
*/
public class ActivityMods extends ActivityWithToolbar
{
private static final boolean ENABLE_REPO_DOWNLOADING = true;
private static final String REPO_URL = "https://raw.githubusercontent.com/vcmi/vcmi-mods-repository/develop/github.json";
private VCMIModsRepo mRepo;
private RecyclerView mRecycler;
private VCMIModContainer mModContainer;
private TextView mErrorMessage;
private View mProgress;
private ModsAdapter mModsAdapter;
@Override
protected void onCreate(@Nullable final Bundle savedInstanceState)
{
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_mods);
initToolbar(R.string.mods_title);
mRepo = new VCMIModsRepo();
mProgress = findViewById(R.id.mods_progress);
mErrorMessage = (TextView) findViewById(R.id.mods_error_text);
mErrorMessage.setVisibility(View.GONE);
mRecycler = (RecyclerView) findViewById(R.id.mods_recycler);
mRecycler.setItemAnimator(new DefaultItemAnimator());
mRecycler.setLayoutManager(new LinearLayoutManager(this));
mRecycler.addItemDecoration(new DividerItemDecoration(this, DividerItemDecoration.VERTICAL));
mRecycler.setVisibility(View.GONE);
mModsAdapter = new ModsAdapter(new OnAdapterItemAction());
mRecycler.setAdapter(mModsAdapter);
new AsyncLoadLocalMods().execute((Void) null);
}
private void loadLocalModData() throws IOException, JSONException
{
final File dataRoot = Storage.getVcmiDataDir(this);
final String internalDataRoot = getFilesDir() + "/" + Const.VCMI_DATA_ROOT_FOLDER_NAME;
final File modsRoot = new File(dataRoot,"/Mods");
final File internalModsRoot = new File(internalDataRoot + "/Mods");
if (!modsRoot.exists() && !internalModsRoot.exists())
{
Log.w(this, "We don't have mods folders");
return;
}
final File[] modsFiles = modsRoot.listFiles();
final File[] internalModsFiles = internalModsRoot.listFiles();
final List<File> topLevelModsFolders = new ArrayList<>();
if (modsFiles != null && modsFiles.length > 0)
{
Collections.addAll(topLevelModsFolders, modsFiles);
}
if (internalModsFiles != null && internalModsFiles.length > 0)
{
Collections.addAll(topLevelModsFolders, internalModsFiles);
}
mModContainer = VCMIModContainer.createContainer(topLevelModsFolders);
final File modConfigFile = new File(dataRoot, "config/modSettings.json");
if (!modConfigFile.exists())
{
Log.w(this, "We don't have mods config");
return;
}
JSONObject rootConfigObj = new JSONObject(FileUtil.read(modConfigFile));
JSONObject activeMods = rootConfigObj.getJSONObject("activeMods");
mModContainer.updateContainerFromConfigJson(activeMods, rootConfigObj.optJSONObject("core"));
Log.i(this, "Loaded mods: " + mModContainer);
}
@Override
public boolean onCreateOptionsMenu(final Menu menu)
{
final MenuInflater menuInflater = getMenuInflater();
menuInflater.inflate(R.menu.menu_mods, menu);
return super.onCreateOptionsMenu(menu);
}
@Override
public boolean onOptionsItemSelected(final MenuItem item)
{
if (item.getItemId() == R.id.menu_mods_download_repo)
{
Log.i(this, "Should download repo now...");
if (ENABLE_REPO_DOWNLOADING)
{
mProgress.setVisibility(View.VISIBLE);
mRepo.init(REPO_URL, new OnModsRepoInitialized()); // disabled because the json is broken anyway
}
else
{
Snackbar.make(findViewById(R.id.mods_data_root), "Loading repo is disabled for now, because .json can't be parsed anyway",
Snackbar.LENGTH_LONG).show();
}
}
return super.onOptionsItemSelected(item);
}
private void handleNoData()
{
mProgress.setVisibility(View.GONE);
mRecycler.setVisibility(View.GONE);
mErrorMessage.setVisibility(View.VISIBLE);
mErrorMessage.setText("Could not load local mods list");
}
private void saveModSettingsToFile()
{
mModContainer.saveToFile(
new File(
Storage.getVcmiDataDir(this),
"config/modSettings.json"));
}
private class OnModsRepoInitialized implements VCMIModsRepo.IOnModsRepoDownloaded
{
@Override
public void onSuccess(ServerResponse<List<VCMIMod>> response)
{
Log.i(this, "Initialized mods repo");
mModContainer.updateFromRepo(response.mContent);
mModsAdapter.updateModsList(mModContainer.submods());
mProgress.setVisibility(View.GONE);
}
@Override
public void onError(final int code)
{
Log.i(this, "Mods repo error: " + code);
}
}
private class AsyncLoadLocalMods extends AsyncTask<Void, Void, Void>
{
@Override
protected void onPreExecute()
{
mProgress.setVisibility(View.VISIBLE);
}
@Override
protected Void doInBackground(final Void... params)
{
try
{
loadLocalModData();
}
catch (IOException e)
{
Log.e(this, "Loading local mod data failed", e);
}
catch (JSONException e)
{
Log.e(this, "Parsing local mod data failed", e);
}
return null;
}
@Override
protected void onPostExecute(final Void aVoid)
{
if (mModContainer == null || !mModContainer.hasSubmods())
{
handleNoData();
}
else
{
mProgress.setVisibility(View.GONE);
mRecycler.setVisibility(View.VISIBLE);
mModsAdapter.updateModsList(mModContainer.submods());
}
}
}
private class OnAdapterItemAction implements ModsAdapter.IOnItemAction
{
@Override
public void onItemPressed(final ModsAdapter.ModItem mod, final RecyclerView.ViewHolder vh)
{
Log.i(this, "Mod pressed: " + mod);
if (mod.mMod.hasSubmods())
{
if (mod.mExpanded)
{
mModsAdapter.detachSubmods(mod, vh);
}
else
{
mModsAdapter.attachSubmods(mod, vh);
mRecycler.scrollToPosition(vh.getAdapterPosition() + 1);
}
mod.mExpanded = !mod.mExpanded;
}
}
@Override
public void onDownloadPressed(final ModsAdapter.ModItem mod, final RecyclerView.ViewHolder vh)
{
Log.i(this, "Mod download pressed: " + mod);
mModsAdapter.downloadProgress(mod, "0%");
installModAsync(mod);
}
@Override
public void onTogglePressed(final ModsAdapter.ModItem item, final ModBaseViewHolder holder)
{
if(!item.mMod.mSystem && item.mMod.mInstalled)
{
item.mMod.mActive = !item.mMod.mActive;
mModsAdapter.notifyItemChanged(holder.getAdapterPosition());
saveModSettingsToFile();
}
}
@Override
public void onUninstall(ModsAdapter.ModItem item, ModBaseViewHolder holder)
{
File installationFolder = item.mMod.installationFolder;
ActivityMods activity = ActivityMods.this;
if(installationFolder != null){
new AlertDialog.Builder(activity)
.setTitle(activity.getString(R.string.mods_removal_title, item.mMod.mName))
.setMessage(activity.getString(R.string.mods_removal_confirmation, item.mMod.mName))
.setIcon(android.R.drawable.ic_dialog_alert)
.setNegativeButton(android.R.string.no, null)
.setPositiveButton(android.R.string.yes, (dialog, whichButton) ->
{
FileUtil.clearDirectory(installationFolder);
installationFolder.delete();
mModsAdapter.modRemoved(item);
})
.show();
}
}
}
private void installModAsync(ModsAdapter.ModItem mod){
File dataDir = Storage.getVcmiDataDir(this);
File modFolder = new File(
new File(dataDir, "Mods"),
mod.mMod.mId.toLowerCase(Locale.US));
InstallModAsync modInstaller = new InstallModAsync(
modFolder,
this,
new InstallModCallback(mod)
);
modInstaller.execute(mod.mMod.mArchiveUrl);
}
public class InstallModCallback implements InstallModAsync.PostDownload
{
private ModsAdapter.ModItem mod;
public InstallModCallback(ModsAdapter.ModItem mod)
{
this.mod = mod;
}
@Override
public void downloadDone(Boolean succeed, File modFolder)
{
if(succeed){
mModsAdapter.modInstalled(mod, modFolder);
}
}
@Override
public void downloadProgress(String... progress)
{
if(progress.length > 0)
{
mModsAdapter.downloadProgress(mod, progress[0]);
}
}
}
}

View File

@ -0,0 +1,53 @@
package eu.vcmi.vcmi;
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.widget.Toolbar;
import android.view.MenuItem;
import android.view.ViewStub;
/**
* @author F
*/
public abstract class ActivityWithToolbar extends ActivityBase
{
@Override
public void setContentView(final int layoutResId)
{
super.setContentView(R.layout.activity_toolbar_wrapper);
final ViewStub contentStub = (ViewStub) findViewById(R.id.toolbar_wrapper_content_stub);
contentStub.setLayoutResource(layoutResId);
contentStub.inflate();
}
@Override
public boolean onOptionsItemSelected(final MenuItem item)
{
if (item.getItemId() == android.R.id.home)
{
finish();
return true;
}
return super.onOptionsItemSelected(item);
}
protected void initToolbar(final int textResId)
{
initToolbar(textResId, false);
}
protected void initToolbar(final int textResId, final boolean isTopLevelActivity)
{
final Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
setSupportActionBar(toolbar);
toolbar.setTitle(textResId);
if (!isTopLevelActivity)
{
final ActionBar bar = getSupportActionBar();
if (bar != null)
{
bar.setDisplayHomeAsUpEnabled(true);
}
}
}
}

View File

@ -0,0 +1,233 @@
package eu.vcmi.vcmi;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.File;
import java.io.IOException;
import eu.vcmi.vcmi.util.FileUtil;
import eu.vcmi.vcmi.util.Log;
/**
* @author F
*/
public class Config
{
public static final String DEFAULT_CODEPAGE = "CP1250";
public static final int DEFAULT_MUSIC_VALUE = 5;
public static final int DEFAULT_SOUND_VALUE = 5;
public static final int DEFAULT_SCREEN_RES_W = 800;
public static final int DEFAULT_SCREEN_RES_H = 600;
public String mCodepage;
public int mResolutionWidth;
public int mResolutionHeight;
public boolean mSwipeEnabled;
public int mVolumeSound;
public int mVolumeMusic;
private String adventureAi;
private double mPointerSpeedMultiplier;
private boolean mUseRelativePointer;
private JSONObject mRawObject;
private boolean mIsModified;
private static JSONObject accessNode(final JSONObject baseObj, String type)
{
if (baseObj == null)
{
return null;
}
return baseObj.optJSONObject(type);
}
private static JSONObject accessScreenResNode(final JSONObject baseObj)
{
if (baseObj == null)
{
return null;
}
final JSONObject video = baseObj.optJSONObject("video");
if (video != null)
{
return video.optJSONObject("screenRes");
}
return null;
}
private static double loadDouble(final JSONObject node, final String key, final double fallback)
{
if (node == null)
{
return fallback;
}
return node.optDouble(key, fallback);
}
@SuppressWarnings("unchecked")
private static <T> T loadEntry(final JSONObject node, final String key, final T fallback)
{
if (node == null)
{
return fallback;
}
final Object value = node.opt(key);
return value == null ? fallback : (T) value;
}
public static Config load(final JSONObject obj)
{
Log.v("loading config from json: " + obj.toString());
final Config config = new Config();
final JSONObject general = accessNode(obj, "general");
final JSONObject server = accessNode(obj, "server");
config.mCodepage = loadEntry(general, "encoding", DEFAULT_CODEPAGE);
config.mVolumeSound = loadEntry(general, "sound", DEFAULT_SOUND_VALUE);
config.mVolumeMusic = loadEntry(general, "music", DEFAULT_MUSIC_VALUE);
config.mSwipeEnabled = loadEntry(general, "swipe", true);
config.adventureAi = loadEntry(server, "playerAI", "VCAI");
config.mUseRelativePointer = loadEntry(general, "userRelativePointer", false);
config.mPointerSpeedMultiplier = loadDouble(general, "relativePointerSpeedMultiplier", 1.0);
final JSONObject screenRes = accessScreenResNode(obj);
config.mResolutionWidth = loadEntry(screenRes, "width", DEFAULT_SCREEN_RES_W);
config.mResolutionHeight = loadEntry(screenRes, "height", DEFAULT_SCREEN_RES_H);
config.mRawObject = obj;
return config;
}
public void updateCodepage(final String s)
{
mCodepage = s;
mIsModified = true;
}
public void updateResolution(final int x, final int y)
{
mResolutionWidth = x;
mResolutionHeight = y;
mIsModified = true;
}
public void updateSwipe(final boolean b)
{
mSwipeEnabled = b;
mIsModified = true;
}
public void updateSound(final int i)
{
mVolumeSound = i;
mIsModified = true;
}
public void updateMusic(final int i)
{
mVolumeMusic = i;
mIsModified = true;
}
public void setAdventureAi(String ai)
{
adventureAi = ai;
mIsModified = true;
}
public String getAdventureAi()
{
return this.adventureAi == null ? "VCAI" : this.adventureAi;
}
public void setPointerSpeedMultiplier(float speedMultiplier)
{
mPointerSpeedMultiplier = speedMultiplier;
mIsModified = true;
}
public float getPointerSpeedMultiplier()
{
return (float)mPointerSpeedMultiplier;
}
public void setPointerMode(boolean isRelative)
{
mUseRelativePointer = isRelative;
mIsModified = true;
}
public boolean getPointerModeIsRelative()
{
return mUseRelativePointer;
}
public void save(final File location) throws IOException, JSONException
{
if (!needsSaving(location))
{
Log.d(this, "Config doesn't need saving");
return;
}
try
{
final String configString = toJson();
FileUtil.write(location, configString);
Log.v(this, "Saved config: " + configString);
}
catch (final Exception e)
{
Log.e(this, "Could not save config", e);
throw e;
}
}
private boolean needsSaving(final File location)
{
return mIsModified || !location.exists();
}
private String toJson() throws JSONException
{
final JSONObject generalNode = accessNode(mRawObject, "general");
final JSONObject serverNode = accessNode(mRawObject, "server");
final JSONObject screenResNode = accessScreenResNode(mRawObject);
final JSONObject root = mRawObject == null ? new JSONObject() : mRawObject;
final JSONObject general = generalNode == null ? new JSONObject() : generalNode;
final JSONObject video = new JSONObject();
final JSONObject screenRes = screenResNode == null ? new JSONObject() : screenResNode;
final JSONObject server = serverNode == null ? new JSONObject() : serverNode;
if (mCodepage != null)
{
general.put("encoding", mCodepage);
}
general.put("swipe", mSwipeEnabled);
general.put("music", mVolumeMusic);
general.put("sound", mVolumeSound);
general.put("userRelativePointer", mUseRelativePointer);
general.put("relativePointerSpeedMultiplier", mPointerSpeedMultiplier);
root.put("general", general);
if(this.adventureAi != null)
{
server.put("playerAI", this.adventureAi);
root.put("server", server);
}
if (mResolutionHeight > 0 && mResolutionWidth > 0)
{
screenRes.put("width", mResolutionWidth);
screenRes.put("height", mResolutionHeight);
video.put("screenRes", screenRes);
root.put("video", video);
}
return root.toString();
}
}

View File

@ -0,0 +1,24 @@
package eu.vcmi.vcmi;
import android.content.Context;
import android.os.Build;
import android.os.Environment;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStreamWriter;
/**
* @author F
*/
public class Const
{
public static final String JNI_METHOD_SUPPRESS = "unused"; // jni methods are marked as unused, because IDE doesn't understand jni calls
// used to disable lint errors about try-with-resources being unsupported on api <19 (it is supported, because retrolambda backports it)
public static final int SUPPRESS_TRY_WITH_RESOURCES_WARNING = Build.VERSION_CODES.KITKAT;
public static final String VCMI_DATA_ROOT_FOLDER_NAME = "vcmi-data";
}

View File

@ -0,0 +1,163 @@
package eu.vcmi.vcmi;
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.os.Environment;
import android.os.Looper;
import android.os.Message;
import android.os.Messenger;
import android.os.RemoteException;
import org.libsdl.app.SDL;
import org.libsdl.app.SDLActivity;
import java.io.File;
import java.lang.ref.WeakReference;
import eu.vcmi.vcmi.util.Log;
/**
* @author F
*/
public class NativeMethods
{
private static WeakReference<Messenger> serverMessengerRef;
public NativeMethods()
{
}
public static native void initClassloader();
public static native void clientSetupJNI();
public static native void createServer();
public static native void notifyServerReady();
public static native void notifyServerClosed();
public static native boolean tryToSaveTheGame();
public static void setupMsg(final Messenger msg)
{
serverMessengerRef = new WeakReference<>(msg);
}
@SuppressWarnings(Const.JNI_METHOD_SUPPRESS)
public static String dataRoot()
{
final Context ctx = SDL.getContext();
String root = Storage.getVcmiDataDir(ctx).getAbsolutePath();
Log.i("Accessing data root: " + root);
return root;
}
// this path is visible only to this application; we can store base vcmi configs etc. there
@SuppressWarnings(Const.JNI_METHOD_SUPPRESS)
public static String internalDataRoot()
{
final Context ctx = SDL.getContext();
String root = new File(ctx.getFilesDir(), Const.VCMI_DATA_ROOT_FOLDER_NAME).getAbsolutePath();
Log.i("Accessing internal data root: " + root);
return root;
}
@SuppressWarnings(Const.JNI_METHOD_SUPPRESS)
public static String nativePath()
{
final Context ctx = SDL.getContext();
Log.i("Accessing ndk path: " + ctx.getApplicationInfo().nativeLibraryDir);
return ctx.getApplicationInfo().nativeLibraryDir;
}
@SuppressWarnings(Const.JNI_METHOD_SUPPRESS)
public static void startServer()
{
Log.i("Got server create request");
final Context ctx = SDL.getContext();
if (!(ctx instanceof VcmiSDLActivity))
{
Log.e("Unexpected context... " + ctx);
return;
}
Intent intent = new Intent(ctx, SDLActivity.class);
intent.setAction(VcmiSDLActivity.NATIVE_ACTION_CREATE_SERVER);
// I probably do something incorrectly, but sending new intent to the activity "normally" breaks SDL events handling (probably detaches jnienv?)
// so instead let's call onNewIntent directly, as out context SHOULD be SDLActivity anyway
((VcmiSDLActivity) ctx).hackCallNewIntentDirectly(intent);
// ctx.startActivity(intent);
}
@SuppressWarnings(Const.JNI_METHOD_SUPPRESS)
public static void killServer()
{
Log.i("Got server close request");
final Context ctx = SDL.getContext();
ctx.stopService(new Intent(ctx, ServerService.class));
Messenger messenger = requireServerMessenger();
try
{
// we need to actually inform client about killing the server, beacuse it needs to unbind service connection before server gets destroyed
messenger.send(Message.obtain(null, VcmiSDLActivity.SERVER_MESSAGE_SERVER_KILLED));
}
catch (RemoteException e)
{
Log.w("Connection with client process broken?");
}
}
@SuppressWarnings(Const.JNI_METHOD_SUPPRESS)
public static void onServerReady()
{
Log.i("Got server ready msg");
Messenger messenger = requireServerMessenger();
try
{
messenger.send(Message.obtain(null, VcmiSDLActivity.SERVER_MESSAGE_SERVER_READY));
}
catch (RemoteException e)
{
Log.w("Connection with client process broken?");
}
}
@SuppressWarnings(Const.JNI_METHOD_SUPPRESS)
public static void showProgress()
{
internalProgressDisplay(true);
}
@SuppressWarnings(Const.JNI_METHOD_SUPPRESS)
public static void hideProgress()
{
internalProgressDisplay(false);
}
private static void internalProgressDisplay(final boolean show)
{
final Context ctx = SDL.getContext();
if (!(ctx instanceof VcmiSDLActivity))
{
return;
}
((SDLActivity) ctx).runOnUiThread(() -> ((VcmiSDLActivity) ctx).displayProgress(show));
}
private static Messenger requireServerMessenger()
{
Messenger msg = serverMessengerRef.get();
if (msg == null)
{
throw new RuntimeException("Broken server messenger");
}
return msg;
}
}

View File

@ -0,0 +1,107 @@
package eu.vcmi.vcmi;
import android.app.Service;
import android.content.Intent;
import android.os.Handler;
import android.os.IBinder;
import android.os.Message;
import android.os.Messenger;
import org.libsdl.app.SDL;
import java.lang.ref.WeakReference;
import eu.vcmi.vcmi.util.LibsLoader;
import eu.vcmi.vcmi.util.Log;
/**
* @author F
*/
public class ServerService extends Service
{
public static final int CLIENT_MESSAGE_CLIENT_REGISTERED = 1;
public static final String INTENT_ACTION_KILL_SERVER = "ServerService.Action.Kill";
final Messenger mMessenger = new Messenger(new IncomingClientMessageHandler(new OnClientRegisteredCallback()));
private Messenger mClient;
@Override
public IBinder onBind(Intent intent)
{
return mMessenger.getBinder();
}
@Override
public int onStartCommand(Intent intent, int flags, int startId)
{
SDL.setContext(ServerService.this);
LibsLoader.loadServerLibs();
if (INTENT_ACTION_KILL_SERVER.equals(intent.getAction()))
{
stopSelf();
}
return START_NOT_STICKY;
}
@Override
public void onDestroy()
{
super.onDestroy();
Log.i(this, "destroyed");
// we need to kill the process to ensure all server data is cleaned up; this isn't a good solution (as we mess with system's
// memory management stuff), but clearing all native data manually would be a pain and we can't force close the server "gracefully", because
// even after onDestroy call, the system can postpone actually finishing the process -- this would break CVCMIServer initialization
System.exit(0);
}
private interface IncomingClientMessageHandlerCallback
{
void onClientRegistered(Messenger client);
}
private static class ServerStartThread extends Thread
{
@Override
public void run()
{
NativeMethods.createServer();
}
}
private static class IncomingClientMessageHandler extends Handler
{
private WeakReference<IncomingClientMessageHandlerCallback> mCallbackRef;
IncomingClientMessageHandler(final IncomingClientMessageHandlerCallback callback)
{
mCallbackRef = new WeakReference<>(callback);
}
@Override
public void handleMessage(Message msg)
{
switch (msg.what)
{
case CLIENT_MESSAGE_CLIENT_REGISTERED:
final IncomingClientMessageHandlerCallback callback = mCallbackRef.get();
if (callback != null)
{
callback.onClientRegistered(msg.replyTo);
}
NativeMethods.setupMsg(msg.replyTo);
new ServerStartThread().start();
break;
default:
super.handleMessage(msg);
}
}
}
private class OnClientRegisteredCallback implements IncomingClientMessageHandlerCallback
{
@Override
public void onClientRegistered(final Messenger client)
{
mClient = client;
}
}
}

View File

@ -0,0 +1,31 @@
package eu.vcmi.vcmi;
import android.content.Context;
import java.io.File;
import java.io.IOException;
import eu.vcmi.vcmi.util.FileUtil;
public class Storage
{
public static File getVcmiDataDir(Context context)
{
File root = context.getExternalFilesDir(null);
return new File(root, Const.VCMI_DATA_ROOT_FOLDER_NAME);
}
public static boolean testH3DataFolder(Context context)
{
return testH3DataFolder(getVcmiDataDir(context));
}
public static boolean testH3DataFolder(final File baseDir)
{
final File testH3Data = new File(baseDir, "Data");
return testH3Data.exists();
}
public static String getH3DataFolder(Context context){
return getVcmiDataDir(context).getAbsolutePath();
}
}

View File

@ -0,0 +1,206 @@
package eu.vcmi.vcmi;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.ServiceConnection;
import android.os.Bundle;
import android.os.Handler;
import android.os.IBinder;
import android.os.Message;
import android.os.Messenger;
import android.os.RemoteException;
import android.view.View;
import android.view.ViewGroup;
import org.libsdl.app.SDLActivity;
import eu.vcmi.vcmi.util.LibsLoader;
import eu.vcmi.vcmi.util.Log;
public class VcmiSDLActivity extends SDLActivity
{
public static final int SERVER_MESSAGE_SERVER_READY = 1000;
public static final int SERVER_MESSAGE_SERVER_KILLED = 1001;
public static final String NATIVE_ACTION_CREATE_SERVER = "SDLActivity.Action.CreateServer";
protected static final int COMMAND_USER = 0x8000;
final Messenger mClientMessenger = new Messenger(
new IncomingServerMessageHandler(
new OnServerRegisteredCallback()));
Messenger mServiceMessenger = null;
boolean mIsServerServiceBound;
private View mProgressBar;
private ServiceConnection mServerServiceConnection = new ServiceConnection()
{
public void onServiceConnected(ComponentName className,
IBinder service)
{
Log.i(this, "Service connection");
mServiceMessenger = new Messenger(service);
mIsServerServiceBound = true;
try
{
Message msg = Message.obtain(null, ServerService.CLIENT_MESSAGE_CLIENT_REGISTERED);
msg.replyTo = mClientMessenger;
mServiceMessenger.send(msg);
}
catch (RemoteException ignored)
{
}
}
public void onServiceDisconnected(ComponentName className)
{
Log.i(this, "Service disconnection");
mServiceMessenger = null;
}
};
public void hackCallNewIntentDirectly(final Intent intent)
{
onNewIntent(intent);
}
public void displayProgress(final boolean show)
{
if (mProgressBar != null)
{
mProgressBar.setVisibility(show ? View.VISIBLE : View.GONE);
}
}
@Override
public void loadLibraries()
{
LibsLoader.loadClientLibs(this);
}
@Override
protected String getMainSharedObject() {
String library = "libvcmi-client.so";
return getContext().getApplicationInfo().nativeLibraryDir + "/" + library;
}
@Override
protected void onNewIntent(final Intent intent)
{
Log.i(this, "Got new intent with action " + intent.getAction());
if (NATIVE_ACTION_CREATE_SERVER.equals(intent.getAction()))
{
initService();
}
}
@Override
protected void onCreate(Bundle savedInstanceState)
{
super.onCreate(savedInstanceState);
if(mBrokenLibraries)
return;
final View outerLayout = getLayoutInflater().inflate(R.layout.activity_game, null, false);
final ViewGroup layout = (ViewGroup) outerLayout.findViewById(R.id.game_outer_frame);
mProgressBar = outerLayout.findViewById(R.id.game_progress);
mLayout.removeView(mSurface);
layout.addView(mSurface);
mLayout = layout;
setContentView(outerLayout);
}
@Override
protected void onDestroy()
{
try
{
// since android can kill the activity unexpectedly (e.g. memory is low or device is inactive for some time), let's try creating
// an autosave so user might be able to resume the game; this isn't a very good impl (we shouldn't really sleep here and hope that the
// save is created, but for now it might suffice
// (better solution: listen for game's confirmation that the save has been created -- this would allow us to inform the users
// on the next app launch that there is an automatic save that they can use)
if (NativeMethods.tryToSaveTheGame())
{
Thread.sleep(1000L);
}
}
catch (final InterruptedException ignored)
{
}
unbindServer();
super.onDestroy();
}
private void initService()
{
unbindServer();
startService(new Intent(this, ServerService.class));
bindService(
new Intent(VcmiSDLActivity.this, ServerService.class),
mServerServiceConnection,
Context.BIND_AUTO_CREATE);
}
private void unbindServer()
{
Log.d(this, "Unbinding server " + mIsServerServiceBound);
if (mIsServerServiceBound)
{
unbindService(mServerServiceConnection);
mIsServerServiceBound = false;
}
}
private interface IncomingServerMessageHandlerCallback
{
void unbindServer();
}
private class OnServerRegisteredCallback implements IncomingServerMessageHandlerCallback
{
@Override
public void unbindServer()
{
VcmiSDLActivity.this.unbindServer();
}
}
private static class IncomingServerMessageHandler extends Handler
{
private VcmiSDLActivity.IncomingServerMessageHandlerCallback mCallback;
IncomingServerMessageHandler(
final VcmiSDLActivity.IncomingServerMessageHandlerCallback callback)
{
mCallback = callback;
}
@Override
public void handleMessage(Message msg)
{
Log.i(this, "Got server msg " + msg);
switch (msg.what)
{
case SERVER_MESSAGE_SERVER_READY:
NativeMethods.notifyServerReady();
break;
case SERVER_MESSAGE_SERVER_KILLED:
if (mCallback != null)
{
mCallback.unbindServer();
}
NativeMethods.notifyServerClosed();
break;
default:
super.handleMessage(msg);
}
}
}
}

View File

@ -0,0 +1,172 @@
package eu.vcmi.vcmi.content;
import android.Manifest;
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Build;
import android.os.Environment;
import androidx.core.app.ActivityCompat;
import java.io.File;
import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.List;
import eu.vcmi.vcmi.Const;
import eu.vcmi.vcmi.R;
import eu.vcmi.vcmi.Storage;
import eu.vcmi.vcmi.util.FileUtil;
import eu.vcmi.vcmi.util.LegacyConfigReader;
import eu.vcmi.vcmi.util.Log;
import eu.vcmi.vcmi.util.SharedPrefs;
/**
* @author F
*/
public class AsyncLauncherInitialization extends AsyncTask<Void, Void, AsyncLauncherInitialization.InitResult>
{
private final WeakReference<ILauncherCallbacks> mCallbackRef;
public AsyncLauncherInitialization(final ILauncherCallbacks callback)
{
mCallbackRef = new WeakReference<>(callback);
}
private InitResult init()
{
InitResult initResult = handleDataFoldersInitialization();
if (!initResult.mSuccess)
{
return initResult;
}
Log.d(this, "Folders check passed");
return initResult;
}
private InitResult handleDataFoldersInitialization()
{
final ILauncherCallbacks callbacks = mCallbackRef.get();
if (callbacks == null)
{
return InitResult.failure("Internal error");
}
final Context ctx = callbacks.ctx();
final File vcmiDir = Storage.getVcmiDataDir(ctx);
final File internalDir = ctx.getFilesDir();
final File vcmiInternalDir = new File(internalDir, Const.VCMI_DATA_ROOT_FOLDER_NAME);
Log.i(this, "Using " + vcmiDir.getAbsolutePath() + " as root vcmi dir");
if(!vcmiInternalDir.exists()) vcmiInternalDir.mkdir();
if(!vcmiDir.exists()) vcmiDir.mkdir();
if (!Storage.testH3DataFolder(ctx))
{
// no h3 data present -> instruct user where to put it
return InitResult.failure(
ctx.getString(
R.string.launcher_error_h3_data_missing,
Storage.getVcmiDataDir(ctx)));
}
final File testVcmiData = new File(vcmiInternalDir, "Mods/vcmi/mod.json");
final boolean internalVcmiDataExisted = testVcmiData.exists();
if (!internalVcmiDataExisted && !FileUtil.unpackVcmiDataToInternalDir(vcmiInternalDir, ctx.getAssets()))
{
// no h3 data present -> instruct user where to put it
return InitResult.failure(ctx.getString(R.string.launcher_error_vcmi_data_internal_missing));
}
final String previousInternalDataHash = callbacks.prefs().load(SharedPrefs.KEY_CURRENT_INTERNAL_ASSET_HASH, null);
final String currentInternalDataHash = FileUtil.readAssetsStream(ctx.getAssets(), "internalDataHash.txt");
if (currentInternalDataHash == null || previousInternalDataHash == null || !currentInternalDataHash.equals(previousInternalDataHash))
{
// we should update the data only if it existed previously (hash is bound to be empty if we have just created the data)
if (internalVcmiDataExisted)
{
Log.i(this, "Internal data needs to be created/updated; old hash=" + previousInternalDataHash
+ ", new hash=" + currentInternalDataHash);
if (!FileUtil.reloadVcmiDataToInternalDir(vcmiInternalDir, ctx.getAssets()))
{
return InitResult.failure(ctx.getString(R.string.launcher_error_vcmi_data_internal_update));
}
}
callbacks.prefs().save(SharedPrefs.KEY_CURRENT_INTERNAL_ASSET_HASH, currentInternalDataHash);
}
return InitResult.success();
}
@Override
protected InitResult doInBackground(final Void... params)
{
return init();
}
@Override
protected void onPostExecute(final InitResult initResult)
{
final ILauncherCallbacks callbacks = mCallbackRef.get();
if (callbacks == null)
{
return;
}
if (initResult.mSuccess)
{
callbacks.onInitSuccess();
}
else
{
callbacks.onInitFailure(initResult);
}
}
public interface ILauncherCallbacks
{
Activity ctx();
SharedPrefs prefs();
void onInitSuccess();
void onInitFailure(InitResult result);
}
public static final class InitResult
{
public final boolean mSuccess;
public final String mMessage;
public boolean mFailSilently;
public InitResult(final boolean success, final String message)
{
mSuccess = success;
mMessage = message;
}
@Override
public String toString()
{
return String.format("success: %s (%s)", mSuccess, mMessage);
}
public static InitResult failure(String message)
{
return new InitResult(false, message);
}
public static InitResult success()
{
return new InitResult(true, "");
}
}
}

View File

@ -0,0 +1,52 @@
package eu.vcmi.vcmi.content;
import android.annotation.SuppressLint;
import android.app.Dialog;
import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.fragment.app.DialogFragment;
import androidx.appcompat.app.AlertDialog;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.TextView;
import java.io.IOException;
import eu.vcmi.vcmi.R;
import eu.vcmi.vcmi.util.FileUtil;
import eu.vcmi.vcmi.util.Log;
/**
* @author F
*/
public class DialogAuthors extends DialogFragment
{
@NonNull
@Override
public Dialog onCreateDialog(final Bundle savedInstanceState)
{
final LayoutInflater inflater = LayoutInflater.from(getActivity());
@SuppressLint("InflateParams") final View inflated = inflater.inflate(R.layout.dialog_authors, null, false);
final TextView vcmiAuthorsView = (TextView) inflated.findViewById(R.id.dialog_authors_vcmi);
final TextView launcherAuthorsView = (TextView) inflated.findViewById(R.id.dialog_authors_launcher);
loadAuthorsContent(vcmiAuthorsView, launcherAuthorsView);
return new AlertDialog.Builder(getActivity())
.setView(inflated)
.create();
}
private void loadAuthorsContent(final TextView vcmiAuthorsView, final TextView launcherAuthorsView)
{
try
{
// to be checked if this should be converted to async load (not really a file operation so it should be okay)
final String authorsContent = FileUtil.read(getResources().openRawResource(R.raw.authors));
vcmiAuthorsView.setText(authorsContent);
launcherAuthorsView.setText("Fay"); // TODO hardcoded for now
}
catch (final IOException e)
{
Log.e(this, "Could not load authors content", e);
}
}
}

View File

@ -0,0 +1,36 @@
package eu.vcmi.vcmi.content;
import androidx.recyclerview.widget.RecyclerView;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;
import eu.vcmi.vcmi.R;
/**
* @author F
*/
public class ModBaseViewHolder extends RecyclerView.ViewHolder
{
final View mModNesting;
final TextView mModName;
ModBaseViewHolder(final View parentView)
{
this(
LayoutInflater.from(parentView.getContext()).inflate(
R.layout.mod_base_adapter_item,
(ViewGroup) parentView,
false),
true);
}
protected ModBaseViewHolder(final View v, final boolean internal)
{
super(v);
mModNesting = itemView.findViewById(R.id.mods_adapter_item_nesting);
mModName = (TextView) itemView.findViewById(R.id.mods_adapter_item_name);
}
}

View File

@ -0,0 +1,254 @@
package eu.vcmi.vcmi.content;
import android.content.Context;
import androidx.recyclerview.widget.RecyclerView;
import android.view.View;
import android.view.ViewGroup;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import eu.vcmi.vcmi.R;
import eu.vcmi.vcmi.mods.VCMIMod;
import eu.vcmi.vcmi.util.Log;
/**
* @author F
*/
public class ModsAdapter extends RecyclerView.Adapter<ModBaseViewHolder>
{
private static final int NESTING_WIDTH_PER_LEVEL = 16;
private static final int VIEWTYPE_MOD = 0;
private static final int VIEWTYPE_FAILED_MOD = 1;
private final List<ModItem> mDataset = new ArrayList<>();
private final IOnItemAction mItemListener;
public ModsAdapter(final IOnItemAction itemListener)
{
mItemListener = itemListener;
}
@Override
public ModBaseViewHolder onCreateViewHolder(final ViewGroup parent, final int viewType)
{
switch (viewType)
{
case VIEWTYPE_MOD:
return new ModsViewHolder(parent);
case VIEWTYPE_FAILED_MOD:
return new ModBaseViewHolder(parent);
default:
Log.e(this, "Unhandled view type: " + viewType);
return null;
}
}
@Override
public void onBindViewHolder(final ModBaseViewHolder holder, final int position)
{
final ModItem item = mDataset.get(position);
final int viewType = getItemViewType(position);
final Context ctx = holder.itemView.getContext();
holder.mModNesting.getLayoutParams().width = item.mNestingLevel * NESTING_WIDTH_PER_LEVEL;
switch (viewType)
{
case VIEWTYPE_MOD:
final ModsViewHolder modHolder = (ModsViewHolder) holder;
modHolder.mModName.setText(item.mMod.mName + ", " + item.mMod.mVersion);
modHolder.mModType.setText(item.mMod.mModType);
if (item.mMod.mSize > 0)
{
modHolder.mModSize.setVisibility(View.VISIBLE);
// TODO unit conversion
modHolder.mModSize.setText(String.format(Locale.getDefault(), "%.1f kB", item.mMod.mSize / 1024.0f));
}
else
{
modHolder.mModSize.setVisibility(View.GONE);
}
modHolder.mModAuthor.setText(ctx.getString(R.string.mods_item_author_template, item.mMod.mAuthor));
modHolder.mStatusIcon.setImageResource(selectModStatusIcon(item.mMod.mActive));
modHolder.mDownloadBtn.setVisibility(View.GONE);
modHolder.mDownloadProgress.setVisibility(View.GONE);
modHolder.mUninstall.setVisibility(View.GONE);
if(!item.mMod.mSystem)
{
if (item.mDownloadProgress != null)
{
modHolder.mDownloadProgress.setText(item.mDownloadProgress);
modHolder.mDownloadProgress.setVisibility(View.VISIBLE);
}
else if (!item.mMod.mInstalled)
{
modHolder.mDownloadBtn.setVisibility(View.VISIBLE);
}
else if (item.mMod.installationFolder != null)
{
modHolder.mUninstall.setVisibility(View.VISIBLE);
}
modHolder.itemView.setOnClickListener(v -> mItemListener.onItemPressed(item, holder));
modHolder.mStatusIcon.setOnClickListener(v -> mItemListener.onTogglePressed(item, holder));
modHolder.mDownloadBtn.setOnClickListener(v -> mItemListener.onDownloadPressed(item, holder));
modHolder.mUninstall.setOnClickListener(v -> mItemListener.onUninstall(item, holder));
}
break;
case VIEWTYPE_FAILED_MOD:
holder.mModName.setText(ctx.getString(R.string.mods_failed_mod_loading, item.mMod.mName));
break;
default:
Log.e(this, "Unhandled view type: " + viewType);
break;
}
}
private int selectModStatusIcon(final boolean active)
{
// TODO distinguishing mods that aren't downloaded or have an update available
if (active)
{
return R.drawable.ic_star_full;
}
return R.drawable.ic_star_empty;
}
@Override
public int getItemViewType(final int position)
{
return mDataset.get(position).mMod.mLoadedCorrectly ? VIEWTYPE_MOD : VIEWTYPE_FAILED_MOD;
}
@Override
public int getItemCount()
{
return mDataset.size();
}
public void attachSubmods(final ModItem mod, final RecyclerView.ViewHolder vh)
{
int adapterPosition = vh.getAdapterPosition();
final List<ModItem> submods = new ArrayList<>();
for (VCMIMod v : mod.mMod.submods())
{
ModItem modItem = new ModItem(v, mod.mNestingLevel + 1);
submods.add(modItem);
}
mDataset.addAll(adapterPosition + 1, submods);
notifyItemRangeInserted(adapterPosition + 1, submods.size());
}
public void detachSubmods(final ModItem mod, final RecyclerView.ViewHolder vh)
{
final int adapterPosition = vh.getAdapterPosition();
final int checkedPosition = adapterPosition + 1;
int detachedElements = 0;
while (checkedPosition < mDataset.size() && mDataset.get(checkedPosition).mNestingLevel > mod.mNestingLevel)
{
++detachedElements;
mDataset.remove(checkedPosition);
}
notifyItemRangeRemoved(checkedPosition, detachedElements);
}
public void updateModsList(List<VCMIMod> mods)
{
mDataset.clear();
List<ModItem> list = new ArrayList<>();
for (VCMIMod mod : mods)
{
ModItem modItem = new ModItem(mod);
list.add(modItem);
}
mDataset.addAll(list);
notifyDataSetChanged();
}
public void modInstalled(ModItem mod, File modFolder)
{
try
{
mod.mMod.updateFromModInfo(modFolder);
mod.mMod.mLoadedCorrectly = true;
mod.mMod.mActive = true; // active by default
mod.mMod.mInstalled = true;
mod.mMod.installationFolder = modFolder;
mod.mDownloadProgress = null;
notifyItemChanged(mDataset.indexOf(mod));
}
catch (Exception ex)
{
Log.e("Failed to install mod", ex);
}
}
public void downloadProgress(ModItem mod, String progress)
{
mod.mDownloadProgress = progress;
notifyItemChanged(mDataset.indexOf(mod));
}
public void modRemoved(ModItem item)
{
int itemIndex = mDataset.indexOf(item);
if(item.mMod.mArchiveUrl != null && item.mMod.mArchiveUrl != "")
{
item.mMod.mInstalled = false;
item.mMod.installationFolder = null;
notifyItemChanged(itemIndex);
}
else
{
mDataset.remove(item);
notifyItemRemoved(itemIndex);
}
}
public interface IOnItemAction
{
void onItemPressed(final ModItem mod, final RecyclerView.ViewHolder vh);
void onDownloadPressed(final ModItem mod, final RecyclerView.ViewHolder vh);
void onTogglePressed(ModItem item, ModBaseViewHolder holder);
void onUninstall(ModItem item, ModBaseViewHolder holder);
}
public static class ModItem
{
public final VCMIMod mMod;
public int mNestingLevel;
public boolean mExpanded;
public String mDownloadProgress;
public ModItem(final VCMIMod mod)
{
this(mod, 0);
}
public ModItem(final VCMIMod mod, final int nestingLevel)
{
mMod = mod;
mNestingLevel = nestingLevel;
mExpanded = false;
}
@Override
public String toString()
{
return mMod.toString();
}
}
}

View File

@ -0,0 +1,35 @@
package eu.vcmi.vcmi.content;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;
import eu.vcmi.vcmi.R;
/**
* @author F
*/
public class ModsViewHolder extends ModBaseViewHolder
{
final TextView mModAuthor;
final TextView mModType;
final TextView mModSize;
final ImageView mStatusIcon;
final View mDownloadBtn;
final TextView mDownloadProgress;
final View mUninstall;
ModsViewHolder(final View parentView)
{
super(LayoutInflater.from(parentView.getContext()).inflate(R.layout.mods_adapter_item, (ViewGroup) parentView, false), true);
mModAuthor = (TextView) itemView.findViewById(R.id.mods_adapter_item_author);
mModType = (TextView) itemView.findViewById(R.id.mods_adapter_item_modtype);
mModSize = (TextView) itemView.findViewById(R.id.mods_adapter_item_size);
mDownloadBtn = itemView.findViewById(R.id.mods_adapter_item_btn_download);
mStatusIcon = (ImageView) itemView.findViewById(R.id.mods_adapter_item_status);
mDownloadProgress = (TextView) itemView.findViewById(R.id.mods_adapter_item_install_progress);
mUninstall = itemView.findViewById(R.id.mods_adapter_item_btn_uninstall);
}
}

View File

@ -0,0 +1,258 @@
package eu.vcmi.vcmi.mods;
import android.text.TextUtils;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import eu.vcmi.vcmi.BuildConfig;
import eu.vcmi.vcmi.util.FileUtil;
import eu.vcmi.vcmi.util.Log;
/**
* @author F
*/
public class VCMIMod
{
protected final Map<String, VCMIMod> mSubmods;
public String mId;
public String mName;
public String mDesc;
public String mVersion;
public String mAuthor;
public String mContact;
public String mModType;
public String mArchiveUrl;
public long mSize;
public File installationFolder;
// config values
public boolean mActive;
public boolean mInstalled;
public boolean mValidated;
public String mChecksum;
// internal
public boolean mLoadedCorrectly;
public boolean mSystem;
protected VCMIMod()
{
mSubmods = new HashMap<>();
}
public static VCMIMod buildFromRepoJson(final String id,
final JSONObject obj,
JSONObject modDownloadData)
{
final VCMIMod mod = new VCMIMod();
mod.mId = id.toLowerCase(Locale.US);
mod.mName = obj.optString("name");
mod.mDesc = obj.optString("description");
mod.mVersion = obj.optString("version");
mod.mAuthor = obj.optString("author");
mod.mContact = obj.optString("contact");
mod.mModType = obj.optString("modType");
mod.mArchiveUrl = modDownloadData.optString("download");
mod.mSize = obj.optLong("size");
mod.mLoadedCorrectly = true;
return mod;
}
public static VCMIMod buildFromConfigJson(final String id, final JSONObject obj) throws JSONException
{
final VCMIMod mod = new VCMIMod();
mod.updateFromConfigJson(id, obj);
mod.mInstalled = true;
return mod;
}
public static VCMIMod buildFromModInfo(final File modPath) throws IOException, JSONException
{
final VCMIMod mod = new VCMIMod();
if (!mod.updateFromModInfo(modPath))
{
return mod;
}
mod.mLoadedCorrectly = true;
mod.mActive = true; // active by default
mod.mInstalled = true;
mod.installationFolder = modPath;
return mod;
}
protected static Map<String, VCMIMod> loadSubmods(final List<File> modsList) throws IOException, JSONException
{
final Map<String, VCMIMod> submods = new HashMap<>();
for (final File f : modsList)
{
if (!f.isDirectory())
{
Log.w("VCMI", "Non-directory encountered in mods dir: " + f.getName());
continue;
}
final VCMIMod submod = buildFromModInfo(f);
if (submod == null)
{
Log.w(null, "Could not build mod in folder " + f + "; ignoring");
continue;
}
submods.put(submod.mId, submod);
}
return submods;
}
public void updateFromConfigJson(final String id, final JSONObject obj) throws JSONException
{
if(mSystem)
{
return;
}
mId = id.toLowerCase(Locale.US);
mActive = obj.optBoolean("active");
mValidated = obj.optBoolean("validated");
mChecksum = obj.optString("checksum");
final JSONObject submods = obj.optJSONObject("mods");
if (submods != null)
{
updateChildrenFromConfigJson(submods);
}
}
protected void updateChildrenFromConfigJson(final JSONObject submods) throws JSONException
{
final JSONArray names = submods.names();
for (int i = 0; i < names.length(); ++i)
{
final String modId = names.getString(i);
final String normalizedModId = modId.toLowerCase(Locale.US);
if (!mSubmods.containsKey(normalizedModId))
{
Log.w(this, "Mod present in config but not found in /Mods; ignoring: " + modId);
continue;
}
mSubmods.get(normalizedModId).updateFromConfigJson(modId, submods.getJSONObject(modId));
}
}
public boolean updateFromModInfo(final File modPath) throws IOException, JSONException
{
final File modInfoFile = new File(modPath, "mod.json");
if (!modInfoFile.exists())
{
Log.w(this, "Mod info doesn't exist");
mName = modPath.getAbsolutePath();
return false;
}
try
{
final JSONObject modInfoContent = new JSONObject(FileUtil.read(modInfoFile));
mId = modPath.getName().toLowerCase(Locale.US);
mName = modInfoContent.optString("name");
mDesc = modInfoContent.optString("description");
mVersion = modInfoContent.optString("version");
mAuthor = modInfoContent.optString("author");
mContact = modInfoContent.optString("contact");
mModType = modInfoContent.optString("modType");
mSystem = mId.equals("vcmi");
final File submodsDir = new File(modPath, "Mods");
if (submodsDir.exists())
{
final List<File> submodsFiles = new ArrayList<>();
Collections.addAll(submodsFiles, submodsDir.listFiles());
mSubmods.putAll(loadSubmods(submodsFiles));
}
return true;
}
catch (final JSONException ex)
{
mName = modPath.getAbsolutePath();
return false;
}
}
@Override
public String toString()
{
if (!BuildConfig.DEBUG)
{
return "";
}
return String.format("mod:[id:%s,active:%s,submods:[%s]]", mId, mActive, TextUtils.join(",", mSubmods.values()));
}
protected void submodsToJson(final JSONObject modsRoot) throws JSONException
{
for (final VCMIMod submod : mSubmods.values())
{
final JSONObject submodEntry = new JSONObject();
submod.toJsonInternal(submodEntry);
modsRoot.put(submod.mId, submodEntry);
}
}
protected void toJsonInternal(final JSONObject root) throws JSONException
{
root.put("active", mActive);
root.put("validated", mValidated);
if (!TextUtils.isEmpty(mChecksum))
{
root.put("checksum", mChecksum);
}
if (!mSubmods.isEmpty())
{
JSONObject submods = new JSONObject();
submodsToJson(submods);
root.put("mods", submods);
}
}
public boolean hasSubmods()
{
return !mSubmods.isEmpty();
}
public List<VCMIMod> submods()
{
final ArrayList<VCMIMod> ret = new ArrayList<>();
ret.addAll(mSubmods.values());
Collections.sort(ret, new Comparator<VCMIMod>()
{
@Override
public int compare(VCMIMod left, VCMIMod right)
{
return left.mName.compareTo(right.mName);
}
});
return ret;
}
protected void updateFrom(VCMIMod other)
{
this.mModType = other.mModType;
this.mAuthor = other.mAuthor;
this.mDesc = other.mDesc;
this.mArchiveUrl = other.mArchiveUrl;
}
}

View File

@ -0,0 +1,106 @@
package eu.vcmi.vcmi.mods;
import android.text.TextUtils;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.File;
import java.io.IOException;
import java.util.List;
import java.util.Locale;
import eu.vcmi.vcmi.BuildConfig;
import eu.vcmi.vcmi.util.FileUtil;
import eu.vcmi.vcmi.util.Log;
/**
* @author F
*/
public class VCMIModContainer extends VCMIMod
{
private VCMIMod mCoreStatus; // kept here to correctly save core object to modSettings
private VCMIModContainer()
{
}
public static VCMIModContainer createContainer(final List<File> modsList) throws IOException, JSONException
{
final VCMIModContainer mod = new VCMIModContainer();
mod.mSubmods.putAll(loadSubmods(modsList));
return mod;
}
public void updateContainerFromConfigJson(final JSONObject modsList, final JSONObject coreStatus) throws JSONException
{
updateChildrenFromConfigJson(modsList);
if (coreStatus != null)
{
mCoreStatus = VCMIMod.buildFromConfigJson("core", coreStatus);
}
}
public void updateFromRepo(List<VCMIMod> repoMods){
for (VCMIMod mod : repoMods)
{
final String normalizedModId = mod.mId.toLowerCase(Locale.US);
if(mSubmods.containsKey(normalizedModId)){
VCMIMod existing = mSubmods.get(normalizedModId);
existing.updateFrom(mod);
}
else{
mSubmods.put(normalizedModId, mod);
}
}
}
@Override
public String toString()
{
if (!BuildConfig.DEBUG)
{
return "";
}
return String.format("mods:[%s]", TextUtils.join(",", mSubmods.values()));
}
public void saveToFile(final File location)
{
try
{
FileUtil.write(location, toJson());
}
catch (Exception e)
{
Log.e(this, "Could not save mod settings", e);
}
}
protected String toJson() throws JSONException
{
final JSONObject root = new JSONObject();
final JSONObject activeMods = new JSONObject();
final JSONObject coreStatus = new JSONObject();
root.put("activeMods", activeMods);
submodsToJson(activeMods);
coreStatusToJson(coreStatus);
root.put("core", coreStatus);
return root.toString();
}
private void coreStatusToJson(final JSONObject coreStatus) throws JSONException
{
if (mCoreStatus == null)
{
mCoreStatus = new VCMIMod();
mCoreStatus.mId = "core";
mCoreStatus.mActive = true;
}
mCoreStatus.toJsonInternal(coreStatus);
}
}

View File

@ -0,0 +1,108 @@
package eu.vcmi.vcmi.mods;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.util.ArrayList;
import java.util.List;
import eu.vcmi.vcmi.util.AsyncRequest;
import eu.vcmi.vcmi.util.Log;
import eu.vcmi.vcmi.util.ServerResponse;
/**
* @author F
*/
public class VCMIModsRepo
{
private final List<VCMIMod> mModsList;
private IOnModsRepoDownloaded mCallback;
public VCMIModsRepo()
{
mModsList = new ArrayList<>();
}
public void init(final String url, final IOnModsRepoDownloaded callback)
{
mCallback = callback;
new AsyncLoadRepo().execute(url);
}
public interface IOnModsRepoDownloaded
{
void onSuccess(ServerResponse<List<VCMIMod>> response);
void onError(final int code);
}
private class AsyncLoadRepo extends AsyncRequest<List<VCMIMod>>
{
@Override
protected ServerResponse<List<VCMIMod>> doInBackground(final String... params)
{
ServerResponse<List<VCMIMod>> serverResponse = sendRequest(params[0]);
if (serverResponse.isValid())
{
final List<VCMIMod> mods = new ArrayList<>();
try
{
JSONObject jsonContent = new JSONObject(serverResponse.mRawContent);
final JSONArray names = jsonContent.names();
for (int i = 0; i < names.length(); ++i)
{
try
{
String name = names.getString(i);
JSONObject modDownloadData = jsonContent.getJSONObject(name);
if(modDownloadData.has("mod"))
{
String modFileAddress = modDownloadData.getString("mod");
ServerResponse<List<VCMIMod>> modFile = sendRequest(modFileAddress);
if (!modFile.isValid())
{
continue;
}
JSONObject modJson = new JSONObject(modFile.mRawContent);
mods.add(VCMIMod.buildFromRepoJson(name, modJson, modDownloadData));
}
else
{
mods.add(VCMIMod.buildFromRepoJson(name, modDownloadData, modDownloadData));
}
}
catch (JSONException e)
{
Log.e(this, "Could not parse the response as json", e);
}
}
serverResponse.mContent = mods;
}
catch (JSONException e)
{
Log.e(this, "Could not parse the response as json", e);
serverResponse.mCode = ServerResponse.LOCAL_ERROR_PARSING;
}
}
return serverResponse;
}
@Override
protected void onPostExecute(final ServerResponse<List<VCMIMod>> response)
{
if (response.isValid())
{
mModsList.clear();
mModsList.addAll(response.mContent);
mCallback.onSuccess(response);
}
else
{
mCallback.onError(response.mCode);
}
}
}
}

View File

@ -0,0 +1,46 @@
package eu.vcmi.vcmi.settings;
import androidx.appcompat.app.AppCompatActivity;
import eu.vcmi.vcmi.Config;
import eu.vcmi.vcmi.R;
/**
* @author F
*/
public class AdventureAiController extends LauncherSettingWithDialogController<String, Config>
{
public AdventureAiController(final AppCompatActivity activity)
{
super(activity);
}
@Override
protected LauncherSettingDialog<String> dialog()
{
return new AdventureAiSelectionDialog();
}
@Override
public void onItemChosen(final String item)
{
mConfig.setAdventureAi(item);
updateContent();
}
@Override
protected String mainText()
{
return mActivity.getString(R.string.launcher_btn_adventure_ai_title);
}
@Override
protected String subText()
{
if (mConfig == null)
{
return "";
}
return mConfig.getAdventureAi();
}
}

View File

@ -0,0 +1,37 @@
package eu.vcmi.vcmi.settings;
import java.util.ArrayList;
import java.util.List;
import eu.vcmi.vcmi.R;
/**
* @author F
*/
public class AdventureAiSelectionDialog extends LauncherSettingDialog<String>
{
private static final List<String> AVAILABLE_AI = new ArrayList<>();
static
{
AVAILABLE_AI.add("VCAI");
AVAILABLE_AI.add("Nullkiller");
}
public AdventureAiSelectionDialog()
{
super(AVAILABLE_AI);
}
@Override
protected int dialogTitleResId()
{
return R.string.launcher_btn_adventure_ai_title;
}
@Override
protected CharSequence itemName(final String item)
{
return item;
}
}

View File

@ -0,0 +1,48 @@
package eu.vcmi.vcmi.settings;
import androidx.appcompat.app.AppCompatActivity;
import eu.vcmi.vcmi.Config;
import eu.vcmi.vcmi.R;
/**
* @author F
*/
public class CodepageSettingController extends LauncherSettingWithDialogController<String, Config>
{
public CodepageSettingController(final AppCompatActivity activity)
{
super(activity);
}
@Override
protected LauncherSettingDialog<String> dialog()
{
return new CodepageSettingDialog();
}
@Override
public void onItemChosen(final String item)
{
mConfig.updateCodepage(item);
updateContent();
}
@Override
protected String mainText()
{
return mActivity.getString(R.string.launcher_btn_cp_title);
}
@Override
protected String subText()
{
if (mConfig == null)
{
return "";
}
return mConfig.mCodepage == null || mConfig.mCodepage.isEmpty()
? mActivity.getString(R.string.launcher_btn_cp_subtitle_unknown)
: mActivity.getString(R.string.launcher_btn_cp_subtitle, mConfig.mCodepage);
}
}

View File

@ -0,0 +1,40 @@
package eu.vcmi.vcmi.settings;
import java.util.ArrayList;
import java.util.List;
import eu.vcmi.vcmi.R;
/**
* @author F
*/
public class CodepageSettingDialog extends LauncherSettingDialog<String>
{
private static final List<String> AVAILABLE_CODEPAGES = new ArrayList<>();
static
{
AVAILABLE_CODEPAGES.add("CP1250");
AVAILABLE_CODEPAGES.add("CP1251");
AVAILABLE_CODEPAGES.add("CP1252");
AVAILABLE_CODEPAGES.add("GBK");
AVAILABLE_CODEPAGES.add("GB2312");
}
public CodepageSettingDialog()
{
super(AVAILABLE_CODEPAGES);
}
@Override
protected int dialogTitleResId()
{
return R.string.launcher_btn_cp_title;
}
@Override
protected CharSequence itemName(final String item)
{
return item;
}
}

View File

@ -0,0 +1,181 @@
package eu.vcmi.vcmi.settings;
import android.app.Activity;
import android.content.Intent;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Environment;
import android.provider.DocumentsContract;
import android.view.View;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.List;
import androidx.appcompat.app.AppCompatActivity;
import androidx.documentfile.provider.DocumentFile;
import androidx.loader.content.AsyncTaskLoader;
import eu.vcmi.vcmi.R;
import eu.vcmi.vcmi.Storage;
import eu.vcmi.vcmi.util.FileUtil;
public class CopyDataController extends LauncherSettingController<Void, Void>
{
public static final int PICK_EXTERNAL_VCMI_DATA_TO_COPY = 3;
private String progress;
public CopyDataController(final AppCompatActivity act)
{
super(act);
}
@Override
protected String mainText()
{
return mActivity.getString(R.string.launcher_btn_import_title);
}
@Override
protected String subText()
{
if (progress != null)
{
return progress;
}
return mActivity.getString(R.string.launcher_btn_import_description);
}
@Override
public void onClick(final View v)
{
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
intent.putExtra(
DocumentsContract.EXTRA_INITIAL_URI,
Uri.fromFile(new File(Environment.getExternalStorageDirectory(), "vcmi-data")));
mActivity.startActivityForResult(intent, PICK_EXTERNAL_VCMI_DATA_TO_COPY);
}
public void copyData(Uri folderToCopy)
{
AsyncCopyData copyTask = new AsyncCopyData(mActivity, folderToCopy);
copyTask.execute();
}
private class AsyncCopyData extends AsyncTask<String, String, Boolean>
{
private Activity owner;
private Uri folderToCopy;
public AsyncCopyData(Activity owner, Uri folderToCopy)
{
this.owner = owner;
this.folderToCopy = folderToCopy;
}
@Override
protected Boolean doInBackground(final String... params)
{
File targetDir = Storage.getVcmiDataDir(owner);
DocumentFile sourceDir = DocumentFile.fromTreeUri(owner, folderToCopy);
ArrayList<String> allowedFolders = new ArrayList<String>();
allowedFolders.add("Data");
allowedFolders.add("Mp3");
allowedFolders.add("Maps");
allowedFolders.add("Saves");
allowedFolders.add("Mods");
allowedFolders.add("config");
return copyDirectory(targetDir, sourceDir, allowedFolders);
}
@Override
protected void onPostExecute(Boolean result)
{
super.onPostExecute(result);
if (result)
{
CopyDataController.this.progress = null;
CopyDataController.this.updateContent();
}
}
@Override
protected void onProgressUpdate(String... values)
{
CopyDataController.this.progress = values[0];
CopyDataController.this.updateContent();
}
private boolean copyDirectory(File targetDir, DocumentFile sourceDir, List<String> allowed)
{
if (!targetDir.exists())
{
targetDir.mkdir();
}
for (DocumentFile child : sourceDir.listFiles())
{
if (allowed != null && !allowed.contains(child.getName()))
{
continue;
}
File exported = new File(targetDir, child.getName());
if (child.isFile())
{
publishProgress(owner.getString(R.string.launcher_progress_copy,
child.getName()));
if (!exported.exists())
{
try
{
exported.createNewFile();
}
catch (IOException e)
{
publishProgress("Failed to copy file " + child.getName());
return false;
}
}
try (
final OutputStream targetStream = new FileOutputStream(exported, false);
final InputStream sourceStream = owner.getContentResolver()
.openInputStream(child.getUri()))
{
FileUtil.copyStream(sourceStream, targetStream);
}
catch (IOException e)
{
publishProgress("Failed to copy file " + child.getName());
return false;
}
}
if (child.isDirectory() && !copyDirectory(exported, child, null))
{
return false;
}
}
return true;
}
}
}

View File

@ -0,0 +1,19 @@
package eu.vcmi.vcmi.settings;
import eu.vcmi.vcmi.Config;
import eu.vcmi.vcmi.util.SharedPrefs;
/**
* @author F
*/
public class DoubleConfig
{
public final Config mConfig;
public final SharedPrefs mPrefs;
public DoubleConfig(final Config config, final SharedPrefs prefs)
{
mConfig = config;
mPrefs = prefs;
}
}

View File

@ -0,0 +1,168 @@
package eu.vcmi.vcmi.settings;
import android.app.Activity;
import android.content.Intent;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Environment;
import android.provider.DocumentsContract;
import android.view.View;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import androidx.appcompat.app.AppCompatActivity;
import androidx.documentfile.provider.DocumentFile;
import eu.vcmi.vcmi.R;
import eu.vcmi.vcmi.Storage;
import eu.vcmi.vcmi.util.FileUtil;
public class ExportDataController extends LauncherSettingController<Void, Void>
{
public static final int PICK_DIRECTORY_TO_EXPORT = 4;
private String progress;
public ExportDataController(final AppCompatActivity act)
{
super(act);
}
@Override
protected String mainText()
{
return mActivity.getString(R.string.launcher_btn_export_title);
}
@Override
protected String subText()
{
if (progress != null)
{
return progress;
}
return mActivity.getString(R.string.launcher_btn_export_description);
}
@Override
public void onClick(final View v)
{
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
intent.putExtra(
DocumentsContract.EXTRA_INITIAL_URI,
Uri.fromFile(new File(Environment.getExternalStorageDirectory(), "vcmi-data")));
mActivity.startActivityForResult(intent, PICK_DIRECTORY_TO_EXPORT);
}
public void copyData(Uri targetFolder)
{
AsyncCopyData copyTask = new AsyncCopyData(mActivity, targetFolder);
copyTask.execute();
}
private class AsyncCopyData extends AsyncTask<String, String, Boolean>
{
private Activity owner;
private Uri targetFolder;
public AsyncCopyData(Activity owner, Uri targetFolder)
{
this.owner = owner;
this.targetFolder = targetFolder;
}
@Override
protected Boolean doInBackground(final String... params)
{
File targetDir = Storage.getVcmiDataDir(owner);
DocumentFile sourceDir = DocumentFile.fromTreeUri(owner, targetFolder);
return copyDirectory(targetDir, sourceDir);
}
@Override
protected void onPostExecute(Boolean result)
{
super.onPostExecute(result);
if (result)
{
ExportDataController.this.progress = null;
ExportDataController.this.updateContent();
}
}
@Override
protected void onProgressUpdate(String... values)
{
ExportDataController.this.progress = values[0];
ExportDataController.this.updateContent();
}
private boolean copyDirectory(File sourceDir, DocumentFile targetDir)
{
for (File child : sourceDir.listFiles())
{
DocumentFile exported = targetDir.findFile(child.getName());
if (child.isFile())
{
publishProgress(owner.getString(R.string.launcher_progress_copy,
child.getName()));
if (exported == null)
{
try
{
exported = targetDir.createFile(
"application/octet-stream",
child.getName());
}
catch (UnsupportedOperationException e)
{
publishProgress("Failed to copy file " + child.getName());
return false;
}
}
try(
final OutputStream targetStream = owner.getContentResolver()
.openOutputStream(exported.getUri());
final InputStream sourceStream = new FileInputStream(child))
{
FileUtil.copyStream(sourceStream, targetStream);
}
catch (IOException e)
{
publishProgress("Failed to copy file " + child.getName());
return false;
}
}
if (child.isDirectory())
{
if (exported == null)
{
exported = targetDir.createDirectory(child.getName());
}
if(!copyDirectory(child, exported))
{
return false;
}
}
}
return true;
}
}
}

View File

@ -0,0 +1,75 @@
package eu.vcmi.vcmi.settings;
import androidx.appcompat.app.AppCompatActivity;
import android.view.View;
import android.widget.TextView;
import eu.vcmi.vcmi.R;
/**
* @author F
*/
public abstract class LauncherSettingController<TSetting, TConf> implements View.OnClickListener
{
protected AppCompatActivity mActivity;
protected TConf mConfig;
private View mSettingViewRoot;
private TextView mSettingsTextMain;
private TextView mSettingsTextSub;
LauncherSettingController(final AppCompatActivity act)
{
mActivity = act;
}
public final LauncherSettingController<TSetting, TConf> init(final int rootViewResId)
{
return init(rootViewResId, null);
}
public final LauncherSettingController<TSetting, TConf> init(final int rootViewResId, final TConf config)
{
mSettingViewRoot = mActivity.findViewById(rootViewResId);
mSettingViewRoot.setOnClickListener(this);
mSettingsTextMain = (TextView) mSettingViewRoot.findViewById(R.id.inc_launcher_btn_main);
mSettingsTextSub = (TextView) mSettingViewRoot.findViewById(R.id.inc_launcher_btn_sub);
childrenInit(mSettingViewRoot);
updateConfig(config);
updateContent();
return this;
}
protected void childrenInit(final View root)
{
}
public void updateConfig(final TConf conf)
{
mConfig = conf;
updateContent();
}
public void updateContent()
{
mSettingsTextMain.setText(mainText());
if (mSettingsTextSub != null)
{
mSettingsTextSub.setText(subText());
}
}
protected abstract String mainText();
protected abstract String subText();
public void show()
{
mSettingViewRoot.setVisibility(View.VISIBLE);
}
public void hide()
{
mSettingViewRoot.setVisibility(View.GONE);
}
}

View File

@ -0,0 +1,73 @@
package eu.vcmi.vcmi.settings;
import android.app.Dialog;
import android.content.DialogInterface;
import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.fragment.app.DialogFragment;
import androidx.appcompat.app.AlertDialog;
import java.util.ArrayList;
import java.util.List;
import eu.vcmi.vcmi.util.Log;
/**
* @author F
*/
public abstract class LauncherSettingDialog<T> extends DialogFragment
{
protected final List<T> mDataset;
private IOnItemChosen<T> mObserver;
protected LauncherSettingDialog(final List<T> dataset)
{
mDataset = dataset;
}
public void observe(final IOnItemChosen<T> observer)
{
mObserver = observer;
}
protected abstract CharSequence itemName(T item);
protected abstract int dialogTitleResId();
@NonNull
@Override
public Dialog onCreateDialog(final Bundle savedInstanceState)
{
List<CharSequence> list = new ArrayList<>();
for (T t : mDataset)
{
CharSequence charSequence = itemName(t);
list.add(charSequence);
}
return new AlertDialog.Builder(getActivity())
.setTitle(dialogTitleResId())
.setItems(
list.toArray(new CharSequence[0]),
this::onItemChosenInternal)
.create();
}
private void onItemChosenInternal(final DialogInterface dialog, final int index)
{
final T chosenItem = mDataset.get(index);
Log.d(this, "Chosen item: " + chosenItem);
dialog.dismiss();
if (mObserver != null)
{
mObserver.onItemChosen(chosenItem);
}
}
public interface IOnItemChosen<V>
{
void onItemChosen(V item);
}
}

View File

@ -0,0 +1,31 @@
package eu.vcmi.vcmi.settings;
import androidx.appcompat.app.AppCompatActivity;
import android.view.View;
import eu.vcmi.vcmi.util.Log;
/**
* @author F
*/
public abstract class LauncherSettingWithDialogController<T, Conf> extends LauncherSettingController<T, Conf>
implements LauncherSettingDialog.IOnItemChosen<T>
{
public static final String SETTING_DIALOG_ID = "settings.dialog";
protected LauncherSettingWithDialogController(final AppCompatActivity act)
{
super(act);
}
@Override
public void onClick(final View v)
{
Log.i(this, "Showing dialog");
final LauncherSettingDialog<T> dialog = dialog();
dialog.observe(this); // TODO rebinding dialogs on activity config changes
dialog.show(mActivity.getSupportFragmentManager(), SETTING_DIALOG_ID);
}
protected abstract LauncherSettingDialog<T> dialog();
}

View File

@ -0,0 +1,83 @@
package eu.vcmi.vcmi.settings;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.widget.AppCompatSeekBar;
import android.view.View;
import android.widget.SeekBar;
import eu.vcmi.vcmi.R;
/**
* @author F
*/
public abstract class LauncherSettingWithSliderController<T, Conf> extends LauncherSettingController<T, Conf>
{
private AppCompatSeekBar mSlider;
private final int mSliderMin;
private final int mSliderMax;
protected LauncherSettingWithSliderController(final AppCompatActivity act, final int min, final int max)
{
super(act);
mSliderMin = min;
mSliderMax = max;
}
@Override
protected void childrenInit(final View root)
{
mSlider = (AppCompatSeekBar) root.findViewById(R.id.inc_launcher_btn_slider);
if (mSliderMax <= mSliderMin)
{
throw new IllegalArgumentException("slider min>=max");
}
mSlider.setMax(mSliderMax - mSliderMin);
mSlider.setOnSeekBarChangeListener(new OnValueChangedListener());
}
protected abstract void onValueChanged(final int v);
protected abstract int currentValue();
@Override
public void updateContent()
{
super.updateContent();
mSlider.setProgress(currentValue() + mSliderMin);
}
@Override
protected String subText()
{
return null; // not used with slider settings
}
@Override
public void onClick(final View v)
{
// not used with slider settings
}
private class OnValueChangedListener implements SeekBar.OnSeekBarChangeListener
{
@Override
public void onProgressChanged(final SeekBar seekBar, final int progress, final boolean fromUser)
{
if (fromUser)
{
onValueChanged(progress);
}
}
@Override
public void onStartTrackingTouch(final SeekBar seekBar)
{
}
@Override
public void onStopTrackingTouch(final SeekBar seekBar)
{
}
}
}

View File

@ -0,0 +1,38 @@
package eu.vcmi.vcmi.settings;
import androidx.appcompat.app.AppCompatActivity;
import android.view.View;
import eu.vcmi.vcmi.R;
/**
* @author F
*/
public class ModsBtnController extends LauncherSettingController<Void, Void>
{
private View.OnClickListener mOnSelectedAction;
public ModsBtnController(final AppCompatActivity act, final View.OnClickListener onSelectedAction)
{
super(act);
mOnSelectedAction = onSelectedAction;
}
@Override
protected String mainText()
{
return mActivity.getString(R.string.launcher_btn_mods_title);
}
@Override
protected String subText()
{
return mActivity.getString(R.string.launcher_btn_mods_subtitle);
}
@Override
public void onClick(final View v)
{
mOnSelectedAction.onClick(v);
}
}

View File

@ -0,0 +1,40 @@
package eu.vcmi.vcmi.settings;
import androidx.appcompat.app.AppCompatActivity;
import eu.vcmi.vcmi.Config;
import eu.vcmi.vcmi.R;
/**
* @author F
*/
public class MusicSettingController extends LauncherSettingWithSliderController<Integer, Config>
{
public MusicSettingController(final AppCompatActivity act)
{
super(act, 0, 100);
}
@Override
protected void onValueChanged(final int v)
{
mConfig.updateMusic(v);
updateContent();
}
@Override
protected int currentValue()
{
if (mConfig == null)
{
return Config.DEFAULT_MUSIC_VALUE;
}
return mConfig.mVolumeMusic;
}
@Override
protected String mainText()
{
return mActivity.getString(R.string.launcher_btn_music_title);
}
}

View File

@ -0,0 +1,75 @@
package eu.vcmi.vcmi.settings;
import androidx.appcompat.app.AppCompatActivity;
import eu.vcmi.vcmi.Config;
import eu.vcmi.vcmi.R;
/**
* @author F
*/
public class PointerModeSettingController
extends LauncherSettingWithDialogController<PointerModeSettingController.PointerMode, Config>
{
public PointerModeSettingController(final AppCompatActivity activity)
{
super(activity);
}
@Override
protected LauncherSettingDialog<PointerMode> dialog()
{
return new PointerModeSettingDialog();
}
@Override
public void onItemChosen(final PointerMode item)
{
mConfig.setPointerMode(item == PointerMode.RELATIVE);
mConfig.updateSwipe(item.supportsSwipe());
updateContent();
}
@Override
protected String mainText()
{
return mActivity.getString(R.string.launcher_btn_pointermode_title);
}
@Override
protected String subText()
{
if (mConfig == null)
{
return "";
}
return mActivity.getString(R.string.launcher_btn_pointermode_subtitle,
PointerModeSettingDialog.pointerModeToUserString(mActivity, getPointerMode()));
}
private PointerMode getPointerMode()
{
if(mConfig.getPointerModeIsRelative())
{
return PointerMode.RELATIVE;
}
if(mConfig.mSwipeEnabled)
{
return PointerMode.NORMAL_WITH_SWIPE;
}
return PointerMode.NORMAL;
}
public enum PointerMode
{
NORMAL,
NORMAL_WITH_SWIPE,
RELATIVE;
public boolean supportsSwipe()
{
return this == NORMAL_WITH_SWIPE;
}
}
}

View File

@ -0,0 +1,58 @@
package eu.vcmi.vcmi.settings;
import android.content.Context;
import java.util.ArrayList;
import java.util.List;
import eu.vcmi.vcmi.R;
/**
* @author F
*/
public class PointerModeSettingDialog extends LauncherSettingDialog<PointerModeSettingController.PointerMode>
{
private static final List<PointerModeSettingController.PointerMode> POINTER_MODES = new ArrayList<>();
static
{
POINTER_MODES.add(PointerModeSettingController.PointerMode.NORMAL);
POINTER_MODES.add(PointerModeSettingController.PointerMode.NORMAL_WITH_SWIPE);
POINTER_MODES.add(PointerModeSettingController.PointerMode.RELATIVE);
}
public PointerModeSettingDialog()
{
super(POINTER_MODES);
}
public static String pointerModeToUserString(
final Context ctx,
final PointerModeSettingController.PointerMode pointerMode)
{
switch (pointerMode)
{
default:
return "";
case NORMAL:
return ctx.getString(R.string.misc_pointermode_normal);
case NORMAL_WITH_SWIPE:
return ctx.getString(R.string.misc_pointermode_swipe);
case RELATIVE:
return ctx.getString(R.string.misc_pointermode_relative);
}
}
@Override
protected int dialogTitleResId()
{
return R.string.launcher_btn_pointermode_title;
}
@Override
protected CharSequence itemName(final PointerModeSettingController.PointerMode item)
{
return pointerModeToUserString(getContext(), item);
}
}

View File

@ -0,0 +1,51 @@
package eu.vcmi.vcmi.settings;
import androidx.appcompat.app.AppCompatActivity;
import eu.vcmi.vcmi.Config;
import eu.vcmi.vcmi.R;
/**
* @author F
*/
public class PointerMultiplierSettingController
extends LauncherSettingWithDialogController<Float, Config>
{
public PointerMultiplierSettingController(final AppCompatActivity activity)
{
super(activity);
}
@Override
protected LauncherSettingDialog<Float> dialog()
{
return new PointerMultiplierSettingDialog();
}
@Override
public void onItemChosen(final Float item)
{
mConfig.setPointerSpeedMultiplier(item);
updateContent();
}
@Override
protected String mainText()
{
return mActivity.getString(R.string.launcher_btn_pointermulti_title);
}
@Override
protected String subText()
{
if (mConfig == null)
{
return "";
}
String pointerModeString = PointerMultiplierSettingDialog.pointerMultiplierToUserString(
mConfig.getPointerSpeedMultiplier());
return mActivity.getString(R.string.launcher_btn_pointermulti_subtitle, pointerModeString);
}
}

View File

@ -0,0 +1,48 @@
package eu.vcmi.vcmi.settings;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import eu.vcmi.vcmi.R;
/**
* @author F
*/
public class PointerMultiplierSettingDialog extends LauncherSettingDialog<Float>
{
private static final List<Float> AVAILABLE_MULTIPLIERS = new ArrayList<>();
static
{
AVAILABLE_MULTIPLIERS.add(1.0f);
AVAILABLE_MULTIPLIERS.add(1.25f);
AVAILABLE_MULTIPLIERS.add(1.5f);
AVAILABLE_MULTIPLIERS.add(1.75f);
AVAILABLE_MULTIPLIERS.add(2.0f);
AVAILABLE_MULTIPLIERS.add(2.5f);
AVAILABLE_MULTIPLIERS.add(3.0f);
}
public PointerMultiplierSettingDialog()
{
super(AVAILABLE_MULTIPLIERS);
}
@Override
protected int dialogTitleResId()
{
return R.string.launcher_btn_pointermode_title;
}
@Override
protected CharSequence itemName(final Float item)
{
return pointerMultiplierToUserString(item);
}
public static String pointerMultiplierToUserString(final float multiplier)
{
return String.format(Locale.US, "%.2fx", multiplier);
}
}

View File

@ -0,0 +1,66 @@
package eu.vcmi.vcmi.settings;
import androidx.appcompat.app.AppCompatActivity;
import eu.vcmi.vcmi.Config;
import eu.vcmi.vcmi.R;
/**
* @author F
*/
public class ScreenResSettingController extends LauncherSettingWithDialogController<ScreenResSettingController.ScreenRes, Config>
{
public ScreenResSettingController(final AppCompatActivity activity)
{
super(activity);
}
@Override
protected LauncherSettingDialog<ScreenRes> dialog()
{
return new ScreenResSettingDialog(mActivity);
}
@Override
public void onItemChosen(final ScreenRes item)
{
mConfig.updateResolution(item.mWidth, item.mHeight);
updateContent();
}
@Override
protected String mainText()
{
return mActivity.getString(R.string.launcher_btn_res_title);
}
@Override
protected String subText()
{
if (mConfig == null)
{
return "";
}
return mConfig.mResolutionWidth <= 0 || mConfig.mResolutionHeight <= 0
? mActivity.getString(R.string.launcher_btn_res_subtitle_unknown)
: mActivity.getString(R.string.launcher_btn_res_subtitle, mConfig.mResolutionWidth, mConfig.mResolutionHeight);
}
public static class ScreenRes
{
public int mWidth;
public int mHeight;
public ScreenRes(final int width, final int height)
{
mWidth = width;
mHeight = height;
}
@Override
public String toString()
{
return mWidth + "x" + mHeight;
}
}
}

View File

@ -0,0 +1,104 @@
package eu.vcmi.vcmi.settings;
import android.app.Activity;
import org.json.JSONArray;
import org.json.JSONObject;
import java.io.File;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.List;
import java.util.Queue;
import eu.vcmi.vcmi.R;
import eu.vcmi.vcmi.Storage;
import eu.vcmi.vcmi.util.FileUtil;
/**
* @author F
*/
public class ScreenResSettingDialog extends LauncherSettingDialog<ScreenResSettingController.ScreenRes>
{
public ScreenResSettingDialog(Activity mActivity)
{
super(loadResolutions(mActivity));
}
@Override
protected int dialogTitleResId()
{
return R.string.launcher_btn_res_title;
}
@Override
protected CharSequence itemName(final ScreenResSettingController.ScreenRes item)
{
return item.toString();
}
private static List<ScreenResSettingController.ScreenRes> loadResolutions(Activity activity)
{
List<ScreenResSettingController.ScreenRes> availableResolutions = new ArrayList<>();
try
{
File modsFolder = new File(Storage.getVcmiDataDir(activity), "Mods");
Queue<File> folders = new ArrayDeque<File>();
folders.offer(modsFolder);
while (!folders.isEmpty())
{
File folder = folders.poll();
File[] children = folder.listFiles();
if(children == null) continue;
for (File child : children)
{
if (child.isDirectory())
{
folders.add(child);
}
else if (child.getName().equals("resolutions.json"))
{
JSONArray resolutions = new JSONObject(FileUtil.read(child))
.getJSONArray("GUISettings");
for(int index = 0; index < resolutions.length(); index++)
{
try
{
JSONObject resolution = resolutions
.getJSONObject(index)
.getJSONObject("resolution");
availableResolutions.add(new ScreenResSettingController.ScreenRes(
resolution.getInt("x"),
resolution.getInt("y")
));
}
catch (Exception ex)
{
ex.printStackTrace();
}
}
}
}
}
if(availableResolutions.isEmpty())
{
availableResolutions.add(new ScreenResSettingController.ScreenRes(800, 600));
}
}
catch(Exception ex)
{
ex.printStackTrace();
availableResolutions.clear();
availableResolutions.add(new ScreenResSettingController.ScreenRes(800, 600));
}
return availableResolutions;
}
}

View File

@ -0,0 +1,40 @@
package eu.vcmi.vcmi.settings;
import androidx.appcompat.app.AppCompatActivity;
import eu.vcmi.vcmi.Config;
import eu.vcmi.vcmi.R;
/**
* @author F
*/
public class SoundSettingController extends LauncherSettingWithSliderController<Integer, Config>
{
public SoundSettingController(final AppCompatActivity act)
{
super(act, 0, 100);
}
@Override
protected void onValueChanged(final int v)
{
mConfig.updateSound(v);
updateContent();
}
@Override
protected int currentValue()
{
if (mConfig == null)
{
return Config.DEFAULT_SOUND_VALUE;
}
return mConfig.mVolumeSound;
}
@Override
protected String mainText()
{
return mActivity.getString(R.string.launcher_btn_sound_title);
}
}

View File

@ -0,0 +1,39 @@
package eu.vcmi.vcmi.settings;
import androidx.appcompat.app.AppCompatActivity;
import android.view.View;
import eu.vcmi.vcmi.R;
import eu.vcmi.vcmi.util.GeneratedVersion;
/**
* @author F
*/
public class StartGameController extends LauncherSettingController<Void, Void>
{
private View.OnClickListener mOnSelectedAction;
public StartGameController(final AppCompatActivity act, final View.OnClickListener onSelectedAction)
{
super(act);
mOnSelectedAction = onSelectedAction;
}
@Override
protected String mainText()
{
return mActivity.getString(R.string.launcher_btn_start_title);
}
@Override
protected String subText()
{
return mActivity.getString(R.string.launcher_btn_start_subtitle, GeneratedVersion.VCMI_VERSION);
}
@Override
public void onClick(final View v)
{
mOnSelectedAction.onClick(v);
}
}

View File

@ -0,0 +1,49 @@
package eu.vcmi.vcmi.util;
import android.annotation.TargetApi;
import android.os.AsyncTask;
import android.os.Build;
import androidx.annotation.RequiresApi;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.Scanner;
import eu.vcmi.vcmi.Const;
/**
* @author F
*/
public abstract class AsyncRequest<T> extends AsyncTask<String, Void, ServerResponse<T>>
{
@TargetApi(Const.SUPPRESS_TRY_WITH_RESOURCES_WARNING)
protected ServerResponse<T> sendRequest(final String url)
{
try
{
final HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
final int responseCode = conn.getResponseCode();
if (!ServerResponse.isResponseCodeValid(responseCode))
{
return new ServerResponse<>(responseCode, null);
}
try (Scanner s = new java.util.Scanner(conn.getInputStream()).useDelimiter("\\A"))
{
final String response = s.hasNext() ? s.next() : "";
return new ServerResponse<>(responseCode, response);
}
catch (final Exception e)
{
Log.e(this, "Request failed: ", e);
}
}
catch (final Exception e)
{
Log.e(this, "Request failed: ", e);
}
return new ServerResponse<>(ServerResponse.LOCAL_ERROR_IO, null);
}
}

View File

@ -0,0 +1,344 @@
package eu.vcmi.vcmi.util;
import android.annotation.TargetApi;
import android.content.res.AssetManager;
import android.os.Environment;
import android.text.TextUtils;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.nio.charset.Charset;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import java.util.zip.ZipInputStream;
import eu.vcmi.vcmi.Const;
/**
* @author F
*/
@TargetApi(Const.SUPPRESS_TRY_WITH_RESOURCES_WARNING)
public class FileUtil
{
private static final int BUFFER_SIZE = 4096;
public static String read(final InputStream stream) throws IOException
{
try (InputStreamReader reader = new InputStreamReader(stream))
{
return readInternal(reader);
}
}
public static String read(final File file) throws IOException
{
try (FileReader reader = new FileReader(file))
{
return readInternal(reader);
}
catch (final FileNotFoundException ignored)
{
Log.w("Could not load file: " + file);
return null;
}
}
private static String readInternal(final InputStreamReader reader) throws IOException
{
final char[] buffer = new char[BUFFER_SIZE];
int currentRead;
final StringBuilder content = new StringBuilder();
while ((currentRead = reader.read(buffer, 0, BUFFER_SIZE)) >= 0)
{
content.append(buffer, 0, currentRead);
}
return content.toString();
}
public static void write(final File file, final String data) throws IOException
{
if (!ensureWriteable(file))
{
Log.e("Couldn't write " + data + " to " + file);
return;
}
try (final FileWriter fw = new FileWriter(file, false))
{
Log.v(null, "Saving data: " + data + " to " + file.getAbsolutePath());
fw.write(data);
}
}
private static boolean ensureWriteable(final File file)
{
if (file == null)
{
Log.e("Broken path given to fileutil");
return false;
}
final File dir = file.getParentFile();
if (dir.exists() || dir.mkdirs())
{
return true;
}
Log.e("Couldn't create dir " + dir);
return false;
}
public static boolean clearDirectory(final File dir)
{
for (final File f : dir.listFiles())
{
if (f.isDirectory() && !clearDirectory(f))
{
return false;
}
if (!f.delete())
{
return false;
}
}
return true;
}
public static void copyDir(final File srcFile, final File dstFile)
{
File[] files = srcFile.listFiles();
if(!dstFile.exists()) dstFile.mkdir();
if(files == null)
return;
for (File child : files){
File childTarget = new File(dstFile, child.getName());
if(child.isDirectory()){
copyDir(child, childTarget);
}
else{
copyFile(child, childTarget);
}
}
}
public static boolean copyFile(final File srcFile, final File dstFile)
{
if (!srcFile.exists())
{
return false;
}
final File dstDir = dstFile.getParentFile();
if (!dstDir.exists())
{
if (!dstDir.mkdirs())
{
Log.w("Couldn't create dir to copy file: " + dstFile);
return false;
}
}
try (final FileInputStream input = new FileInputStream(srcFile);
final FileOutputStream output = new FileOutputStream(dstFile))
{
copyStream(input, output);
return true;
}
catch (final Exception ex)
{
Log.e("Couldn't copy " + srcFile + " to " + dstFile, ex);
return false;
}
}
public static void copyStream(InputStream source, OutputStream target) throws IOException
{
final byte[] buffer = new byte[BUFFER_SIZE];
int read;
while ((read = source.read(buffer)) != -1)
{
target.write(buffer, 0, read);
}
}
// (when internal data have changed)
public static boolean reloadVcmiDataToInternalDir(final File vcmiInternalDir, final AssetManager assets)
{
return clearDirectory(vcmiInternalDir) && unpackVcmiDataToInternalDir(vcmiInternalDir, assets);
}
public static boolean unpackVcmiDataToInternalDir(final File vcmiInternalDir, final AssetManager assets)
{
try
{
final InputStream inputStream = assets.open("internalData.zip");
final boolean success = unpackZipFile(inputStream, vcmiInternalDir, null);
inputStream.close();
return success;
}
catch (final Exception e)
{
Log.e("Couldn't extract vcmi data to internal dir", e);
return false;
}
}
public static boolean unpackZipFile(
final File inputFile,
final File destDir,
IZipProgressReporter progressReporter)
{
try
{
final InputStream inputStream = new FileInputStream(inputFile);
final boolean success = unpackZipFile(
inputStream,
destDir,
progressReporter);
inputStream.close();
return success;
}
catch (final Exception e)
{
Log.e("Couldn't extract file to " + destDir, e);
return false;
}
}
public static int countFilesInZip(final File zipFile)
{
int totalEntries = 0;
try
{
final InputStream inputStream = new FileInputStream(zipFile);
ZipInputStream is = new ZipInputStream(inputStream);
ZipEntry zipEntry;
while ((zipEntry = is.getNextEntry()) != null)
{
totalEntries++;
}
is.closeEntry();
is.close();
inputStream.close();
}
catch (final Exception e)
{
Log.e("Couldn't count items in zip", e);
}
return totalEntries;
}
public static boolean unpackZipFile(
final InputStream inputStream,
final File destDir,
final IZipProgressReporter progressReporter)
{
try
{
int unpackedEntries = 0;
final byte[] buffer = new byte[BUFFER_SIZE];
ZipInputStream is = new ZipInputStream(inputStream);
ZipEntry zipEntry;
while ((zipEntry = is.getNextEntry()) != null)
{
final String fileName = zipEntry.getName();
final File newFile = new File(destDir, fileName);
if (newFile.exists())
{
Log.d("Already exists: " + newFile.getName());
continue;
}
else if (zipEntry.isDirectory())
{
Log.v("Creating new dir: " + zipEntry);
if (!newFile.mkdirs())
{
Log.e("Couldn't create directory " + newFile.getAbsolutePath());
return false;
}
continue;
}
final File parentFile = new File(newFile.getParent());
if (!parentFile.exists() && !parentFile.mkdirs())
{
Log.e("Couldn't create directory " + parentFile.getAbsolutePath());
return false;
}
final FileOutputStream fos = new FileOutputStream(newFile, false);
int currentRead;
while ((currentRead = is.read(buffer)) > 0)
{
fos.write(buffer, 0, currentRead);
}
fos.flush();
fos.close();
++unpackedEntries;
if(progressReporter != null)
{
progressReporter.onUnpacked(newFile);
}
}
Log.d("Unpacked data (" + unpackedEntries + " entries)");
is.closeEntry();
is.close();
return true;
}
catch (final Exception e)
{
Log.e("Couldn't extract vcmi data to " + destDir, e);
return false;
}
}
public static String configFileLocation(File filesDir)
{
return filesDir + "/config/settings.json";
}
public static String readAssetsStream(final AssetManager assets, final String assetPath)
{
if (assets == null || TextUtils.isEmpty(assetPath))
{
return null;
}
try (java.util.Scanner s = new java.util.Scanner(assets.open(assetPath), "UTF-8").useDelimiter("\\A"))
{
return s.hasNext() ? s.next() : null;
}
catch (final IOException e)
{
Log.e("Couldn't read stream data", e);
return null;
}
}
}

View File

@ -0,0 +1,8 @@
package eu.vcmi.vcmi.util;
import java.io.File;
public interface IZipProgressReporter
{
void onUnpacked(File newFile);
}

View File

@ -0,0 +1,198 @@
package eu.vcmi.vcmi.util;
import android.content.Context;
import android.nfc.FormatException;
import android.os.AsyncTask;
import android.os.Build;
import android.util.Log;
import java.io.BufferedInputStream;
import java.io.File;
import java.io.FileFilter;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.net.URL;
import java.net.URLConnection;
public class InstallModAsync
extends AsyncTask<String, String, Boolean>
implements IZipProgressReporter
{
private static final String TAG = "DOWNLOADFILE";
private static final int DOWNLOAD_PERCENT = 70;
private PostDownload callback;
private File downloadLocation;
private File extractLocation;
private Context context;
private int totalFiles;
private int unpackedFiles;
public InstallModAsync(File extractLocation, Context context, PostDownload callback)
{
this.context = context;
this.callback = callback;
this.extractLocation = extractLocation;
}
@Override
protected Boolean doInBackground(String... args)
{
int count;
try
{
File modsFolder = extractLocation.getParentFile();
if (!modsFolder.exists()) modsFolder.mkdir();
this.downloadLocation = File.createTempFile("tmp", ".zip", modsFolder);
URL url = new URL(args[0]);
URLConnection connection = url.openConnection();
connection.connect();
long lengthOfFile = -1;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N)
{
lengthOfFile = connection.getContentLengthLong();
}
if(lengthOfFile == -1)
{
try
{
lengthOfFile = Long.parseLong(connection.getHeaderField("Content-Length"));
Log.d(TAG, "Length of the file: " + lengthOfFile);
} catch (NumberFormatException ex)
{
Log.d(TAG, "Failed to parse content length", ex);
}
}
if(lengthOfFile == -1)
{
lengthOfFile = 100000000;
Log.d(TAG, "Using dummy length of file");
}
InputStream input = new BufferedInputStream(url.openStream());
FileOutputStream output = new FileOutputStream(downloadLocation); //context.openFileOutput("content.zip", Context.MODE_PRIVATE);
Log.d(TAG, "file saved at " + downloadLocation.getAbsolutePath());
byte data[] = new byte[1024];
long total = 0;
while ((count = input.read(data)) != -1)
{
total += count;
output.write(data, 0, count);
this.publishProgress((int) ((total * DOWNLOAD_PERCENT) / lengthOfFile) + "%");
}
output.flush();
output.close();
input.close();
File tempDir = File.createTempFile("tmp", "", modsFolder);
tempDir.delete();
tempDir.mkdir();
if (!extractLocation.exists()) extractLocation.mkdir();
try
{
totalFiles = FileUtil.countFilesInZip(downloadLocation);
unpackedFiles = 0;
FileUtil.unpackZipFile(downloadLocation, tempDir, this);
return moveModToExtractLocation(tempDir);
}
finally
{
downloadLocation.delete();
FileUtil.clearDirectory(tempDir);
tempDir.delete();
}
} catch (Exception e)
{
Log.e(TAG, "Unhandled exception while installing mod", e);
}
return false;
}
@Override
protected void onProgressUpdate(String... values)
{
callback.downloadProgress(values);
}
@Override
protected void onPostExecute(Boolean result)
{
if (callback != null) callback.downloadDone(result, extractLocation);
}
private boolean moveModToExtractLocation(File tempDir)
{
return moveModToExtractLocation(tempDir, 0);
}
private boolean moveModToExtractLocation(File tempDir, int level)
{
File[] modJson = tempDir.listFiles(new FileFilter()
{
@Override
public boolean accept(File file)
{
return file.getName().equalsIgnoreCase("Mod.json");
}
});
if (modJson != null && modJson.length > 0)
{
File modFolder = modJson[0].getParentFile();
if (!modFolder.renameTo(extractLocation))
{
FileUtil.copyDir(modFolder, extractLocation);
}
return true;
}
if (level <= 1)
{
for (File child : tempDir.listFiles())
{
if (child.isDirectory() && moveModToExtractLocation(child, level + 1))
{
return true;
}
}
}
return false;
}
@Override
public void onUnpacked(File newFile)
{
unpackedFiles++;
int progress = DOWNLOAD_PERCENT
+ (unpackedFiles * (100 - DOWNLOAD_PERCENT) / totalFiles);
publishProgress(progress + "%");
}
public interface PostDownload
{
void downloadDone(Boolean succeed, File modFolder);
void downloadProgress(String... progress);
}
}

View File

@ -0,0 +1,100 @@
package eu.vcmi.vcmi.util;
import android.annotation.TargetApi;
import android.text.TextUtils;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import eu.vcmi.vcmi.Const;
/**
* helper used to retrieve old vcmi config (currently only needed for h3 data path in order to migrate the data to the new location)
*
* @author F
*/
public final class LegacyConfigReader
{
private static void skipBools(final ObjectInputStream stream, final int num) throws IOException
{
for (int i = 0; i < num; ++i)
{
stream.readBoolean();
}
}
private static void skipInts(final ObjectInputStream stream, final int num) throws IOException
{
for (int i = 0; i < num; ++i)
{
stream.readInt();
}
}
@TargetApi(Const.SUPPRESS_TRY_WITH_RESOURCES_WARNING)
public static Config load(final File basePath)
{
final File settingsFile = new File(basePath, "/libsdl-settings.cfg");
if (!settingsFile.exists())
{
Log.i("Legacy config file doesn't exist");
return null;
}
try (final ObjectInputStream stream = new ObjectInputStream(new FileInputStream(settingsFile)))
{
if (stream.readInt() != 5)
{
return null;
}
skipBools(stream, 5);
skipInts(stream, 9);
skipBools(stream, 2);
skipInts(stream, 2);
stream.readBoolean();
skipInts(stream, 2);
skipInts(stream, stream.readInt());
stream.readInt();
skipInts(stream, 6);
stream.readInt();
skipBools(stream, 8);
stream.readInt();
stream.readInt();
for (int i = 0; i < 4; i++)
{
stream.readInt();
stream.readBoolean();
}
skipInts(stream, 5);
final StringBuilder b = new StringBuilder();
final int len = stream.readInt();
for (int i = 0; i < len; i++)
{
b.append(stream.readChar());
}
final Config config = new Config();
config.mDataPath = b.toString();
Log.v("Retrieved legacy data folder name: " + config.mDataPath);
if (!TextUtils.isEmpty(config.mDataPath) && new File(config.mDataPath).exists())
{
// return config only if there actually is a chance of retrieving old data
return config;
}
Log.i("Couldn't find valid data in legacy config");
}
catch (final Exception e)
{
Log.i("Couldn't load legacy config");
}
return null;
}
public static class Config
{
public String mDataPath;
}
}

View File

@ -0,0 +1,67 @@
package eu.vcmi.vcmi.util;
import android.content.Context;
import android.os.Build;
import org.libsdl.app.SDL;
import eu.vcmi.vcmi.NativeMethods;
/**
* @author F
*/
public final class LibsLoader
{
private static void loadLib(final String libName, final boolean onlyForOldApis)
{
if (!onlyForOldApis || Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP)
{
Log.v("Loading native lib: " + libName);
SDL.loadLibrary(libName);
}
}
private static void loadCommon()
{
loadLib("c++_shared", true);
loadLib("iconv", true);
loadLib("boost-system", true);
loadLib("boost-datetime", true);
loadLib("boost-locale", true);
loadLib("boost-filesystem", true);
loadLib("boost-program-options", true);
loadLib("boost-thread", true);
loadLib("SDL2", false);
loadLib("x264", true);
loadLib("avutil", true);
loadLib("swscale", true);
loadLib("swresample", true);
loadLib("postproc", true);
loadLib("avcodec", true);
loadLib("avformat", true);
loadLib("avfilter", true);
loadLib("avdevice", true);
loadLib("minizip", true);
loadLib("vcmi-fuzzylite", true);
loadLib("vcmi-lib", true);
loadLib("SDL2_image", false);
loadLib("SDL2_mixer", false);
loadLib("SDL2_ttf", false);
}
public static void loadClientLibs(Context ctx)
{
loadCommon();
loadLib("vcmi-client", false);
SDL.setContext(ctx);
NativeMethods.clientSetupJNI();
NativeMethods.initClassloader();
}
public static void loadServerLibs()
{
loadCommon();
loadLib("vcmi-server", false);
NativeMethods.initClassloader();
}
}

View File

@ -0,0 +1,156 @@
package eu.vcmi.vcmi.util;
import android.os.Environment;
import java.io.BufferedWriter;
import java.io.FileWriter;
import java.io.IOException;
import java.text.DateFormat;
import java.util.Date;
import eu.vcmi.vcmi.BuildConfig;
import eu.vcmi.vcmi.Const;
/**
* @author F
*/
public class Log
{
private static final boolean LOGGING_ENABLED_CONSOLE = BuildConfig.DEBUG;
private static final boolean LOGGING_ENABLED_FILE = true;
private static final String FILELOG_PATH = "/" + Const.VCMI_DATA_ROOT_FOLDER_NAME + "/cache/VCMI_launcher.log";
private static final String TAG_PREFIX = "VCMI/";
private static final String STATIC_TAG = "static";
private static void log(final int priority, final Object obj, final String msg)
{
logInternal(priority, tag(obj), msg);
}
private static void logInternal(final int priority, final String tagString, final String msg)
{
if (LOGGING_ENABLED_CONSOLE)
{
android.util.Log.println(priority, TAG_PREFIX + tagString, msg);
}
if (LOGGING_ENABLED_FILE) // this is probably very inefficient, but should be enough for now...
{
try
{
final BufferedWriter fileWriter = new BufferedWriter(new FileWriter(Environment.getExternalStorageDirectory() + FILELOG_PATH, true));
fileWriter.write(String.format("[%s] %s: %s\n", formatPriority(priority), tagString, msg));
fileWriter.flush();
fileWriter.close();
}
catch (IOException ignored)
{
}
}
}
private static String formatPriority(final int priority)
{
switch (priority)
{
default:
return "?";
case android.util.Log.VERBOSE:
return "V";
case android.util.Log.DEBUG:
return "D";
case android.util.Log.INFO:
return "I";
case android.util.Log.WARN:
return "W";
case android.util.Log.ERROR:
return "E";
}
}
private static String tag(final Object obj)
{
if (obj == null)
{
return "null";
}
return obj.getClass().getSimpleName();
}
public static void init()
{
if (LOGGING_ENABLED_FILE) // clear previous log
{
try
{
final BufferedWriter fileWriter = new BufferedWriter(new FileWriter(Environment.getExternalStorageDirectory() + FILELOG_PATH, false));
fileWriter.write("Starting VCMI launcher log, " + DateFormat.getDateTimeInstance().format(new Date()) + "\n");
fileWriter.flush();
fileWriter.close();
}
catch (IOException ignored)
{
}
}
}
public static void v(final String msg)
{
logInternal(android.util.Log.VERBOSE, STATIC_TAG, msg);
}
public static void d(final String msg)
{
logInternal(android.util.Log.DEBUG, STATIC_TAG, msg);
}
public static void i(final String msg)
{
logInternal(android.util.Log.INFO, STATIC_TAG, msg);
}
public static void w(final String msg)
{
logInternal(android.util.Log.WARN, STATIC_TAG, msg);
}
public static void e(final String msg)
{
logInternal(android.util.Log.ERROR, STATIC_TAG, msg);
}
public static void v(final Object obj, final String msg)
{
log(android.util.Log.VERBOSE, obj, msg);
}
public static void d(final Object obj, final String msg)
{
log(android.util.Log.DEBUG, obj, msg);
}
public static void i(final Object obj, final String msg)
{
log(android.util.Log.INFO, obj, msg);
}
public static void w(final Object obj, final String msg)
{
log(android.util.Log.WARN, obj, msg);
}
public static void e(final Object obj, final String msg)
{
log(android.util.Log.ERROR, obj, msg);
}
public static void e(final Object obj, final String msg, final Throwable e)
{
log(android.util.Log.ERROR, obj, msg + "\n" + android.util.Log.getStackTraceString(e));
}
public static void e(final String msg, final Throwable e)
{
logInternal(android.util.Log.ERROR, STATIC_TAG, msg + "\n" + android.util.Log.getStackTraceString(e));
}
}

View File

@ -0,0 +1,30 @@
package eu.vcmi.vcmi.util;
/**
* @author F
*/
public class ServerResponse<T>
{
public static final int LOCAL_ERROR_IO = -1;
public static final int LOCAL_ERROR_PARSING = -2;
public int mCode;
public String mRawContent;
public T mContent;
public ServerResponse(final int code, final String content)
{
mCode = code;
mRawContent = content;
}
public static boolean isResponseCodeValid(final int responseCode)
{
return responseCode >= 200 && responseCode < 300;
}
public boolean isValid()
{
return isResponseCodeValid(mCode);
}
}

View File

@ -0,0 +1,92 @@
package eu.vcmi.vcmi.util;
import android.content.Context;
import android.content.SharedPreferences;
import androidx.annotation.NonNull;
/**
* simple shared preferences wrapper
*
* @author F
*/
public class SharedPrefs
{
public static final String KEY_CURRENT_INTERNAL_ASSET_HASH = "KEY_CURRENT_INTERNAL_ASSET_HASH"; // [string]
private static final String VCMI_PREFS_NAME = "VCMIPrefs";
private final SharedPreferences mPrefs;
public SharedPrefs(final Context ctx)
{
mPrefs = ctx.getSharedPreferences(VCMI_PREFS_NAME, Context.MODE_PRIVATE);
}
public void save(final String name, final String value)
{
mPrefs.edit().putString(name, value).apply();
log(name, value, true);
}
public String load(final String name, final String defaultValue)
{
return log(name, mPrefs.getString(name, defaultValue), false);
}
public void save(final String name, final int value)
{
mPrefs.edit().putInt(name, value).apply();
log(name, value, true);
}
public int load(final String name, final int defaultValue)
{
return log(name, mPrefs.getInt(name, defaultValue), false);
}
public void save(final String name, final float value)
{
mPrefs.edit().putFloat(name, value).apply();
log(name, value, true);
}
public float load(final String name, final float defaultValue)
{
return log(name, mPrefs.getFloat(name, defaultValue), false);
}
public void save(final String name, final boolean value)
{
mPrefs.edit().putBoolean(name, value).apply();
log(name, value, true);
}
public boolean load(final String name, final boolean defaultValue)
{
return log(name, mPrefs.getBoolean(name, defaultValue), false);
}
public <T extends Enum<T>> void saveEnum(final String name, final T value)
{
mPrefs.edit().putInt(name, value.ordinal()).apply();
log(name, value, true);
}
@SuppressWarnings("unchecked")
public <T extends Enum<T>> T loadEnum(final String name, @NonNull final T defaultValue)
{
final int rawValue = mPrefs.getInt(name, defaultValue.ordinal());
return (T) log(name, defaultValue.getClass().getEnumConstants()[rawValue], false);
}
private <T> T log(final String key, final T value, final boolean saving)
{
if (saving)
{
Log.v(this, "[prefs saving] " + key + " => " + value);
}
else
{
Log.v(this, "[prefs loading] " + key + " => " + value);
}
return value;
}
}

View File

@ -0,0 +1,58 @@
package eu.vcmi.vcmi.util;
import android.content.Context;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.content.res.Resources;
import android.util.DisplayMetrics;
/**
* @author F
*/
public final class Utils
{
private static String sAppVersionCache;
private Utils()
{
}
public static String appVersionName(final Context ctx)
{
if (sAppVersionCache == null)
{
final PackageManager pm = ctx.getPackageManager();
try
{
final PackageInfo info = pm.getPackageInfo(ctx.getPackageName(), PackageManager.GET_META_DATA);
return sAppVersionCache = info.versionName;
}
catch (final PackageManager.NameNotFoundException e)
{
Log.e(ctx, "Couldn't resolve app version", e);
}
}
return sAppVersionCache;
}
public static float convertDpToPx(final Context ctx, final float dp)
{
return convertDpToPx(ctx.getResources(), dp);
}
public static float convertDpToPx(final Resources res, final float dp)
{
return dp * res.getDisplayMetrics().density;
}
public static float convertPxToDp(final Context ctx, final float px)
{
return convertPxToDp(ctx.getResources(), px);
}
public static float convertPxToDp(final Resources res, final float px)
{
return px / res.getDisplayMetrics().density;
}
}

View File

@ -0,0 +1,52 @@
package eu.vcmi.vcmi.viewmodels;
import android.view.View;
import androidx.lifecycle.ViewModel;
import androidx.databinding.PropertyChangeRegistry;
import androidx.databinding.Observable;
/**
* @author F
*/
public class ObservableViewModel extends ViewModel implements Observable
{
private PropertyChangeRegistry callbacks = new PropertyChangeRegistry();
@Override
public void addOnPropertyChangedCallback(
Observable.OnPropertyChangedCallback callback)
{
callbacks.add(callback);
}
@Override
public void removeOnPropertyChangedCallback(
Observable.OnPropertyChangedCallback callback)
{
callbacks.remove(callback);
}
public int visible(boolean isVisible)
{
return isVisible ? View.VISIBLE : View.GONE;
}
/**
* Notifies observers that all properties of this instance have changed.
*/
void notifyChange() {
callbacks.notifyCallbacks(this, 0, null);
}
/**
* Notifies observers that a specific property has changed. The getter for the
* property that changes should be marked with the @Bindable annotation to
* generate a field in the BR class to be used as the fieldId parameter.
*
* @param fieldId The generated BR id for the Bindable field.
*/
void notifyPropertyChanged(int fieldId) {
callbacks.notifyCallbacks(this, fieldId, null);
}
}

View File

@ -0,0 +1,74 @@
package org.libsdl.app;
import android.content.Context;
import android.text.InputType;
import android.view.KeyEvent;
import android.view.View;
import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.InputConnection;
/* This is a fake invisible editor view that receives the input and defines the
* pan&scan region
*/
public class DummyEdit extends View implements View.OnKeyListener
{
InputConnection ic;
public DummyEdit(Context context)
{
super(context);
setFocusableInTouchMode(true);
setFocusable(true);
setOnKeyListener(this);
}
@Override
public boolean onCheckIsTextEditor()
{
return true;
}
@Override
public boolean onKey(View v, int keyCode, KeyEvent event)
{
return SDLActivity.handleKeyEvent(v, keyCode, event, ic);
}
//
@Override
public boolean onKeyPreIme(int keyCode, KeyEvent event)
{
// As seen on StackOverflow: http://stackoverflow.com/questions/7634346/keyboard-hide-event
// FIXME: Discussion at http://bugzilla.libsdl.org/show_bug.cgi?id=1639
// FIXME: This is not a 100% effective solution to the problem of detecting if the keyboard is showing or not
// FIXME: A more effective solution would be to assume our Layout to be RelativeLayout or LinearLayout
// FIXME: And determine the keyboard presence doing this: http://stackoverflow.com/questions/2150078/how-to-check-visibility-of-software-keyboard-in-android
// FIXME: An even more effective way would be if Android provided this out of the box, but where would the fun be in that :)
if (event.getAction() == KeyEvent.ACTION_UP && keyCode == KeyEvent.KEYCODE_BACK)
{
if (SDLActivity.mTextEdit != null && SDLActivity.mTextEdit.getVisibility() == View.VISIBLE)
{
SDLActivity.onNativeKeyboardFocusLost();
}
}
return super.onKeyPreIme(keyCode, event);
}
@Override
public InputConnection onCreateInputConnection(EditorInfo outAttrs)
{
ic = new SDLInputConnection(this, true);
outAttrs.inputType = InputType.TYPE_CLASS_TEXT |
InputType.TYPE_TEXT_FLAG_MULTI_LINE;
outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_EXTRACT_UI |
EditorInfo.IME_FLAG_NO_FULLSCREEN /* API 11 */;
return ic;
}
public InputConnection getInputConnection()
{
return ic;
}
}

View File

@ -0,0 +1,22 @@
package org.libsdl.app;
import android.hardware.usb.UsbDevice;
interface HIDDevice
{
public int getId();
public int getVendorId();
public int getProductId();
public String getSerialNumber();
public int getVersion();
public String getManufacturerName();
public String getProductName();
public UsbDevice getDevice();
public boolean open();
public int sendFeatureReport(byte[] report);
public int sendOutputReport(byte[] report);
public boolean getFeatureReport(byte[] report);
public void setFrozen(boolean frozen);
public void close();
public void shutdown();
}

View File

@ -0,0 +1,650 @@
package org.libsdl.app;
import android.content.Context;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothGatt;
import android.bluetooth.BluetoothGattCallback;
import android.bluetooth.BluetoothGattCharacteristic;
import android.bluetooth.BluetoothGattDescriptor;
import android.bluetooth.BluetoothManager;
import android.bluetooth.BluetoothProfile;
import android.bluetooth.BluetoothGattService;
import android.hardware.usb.UsbDevice;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import android.os.*;
//import com.android.internal.util.HexDump;
import java.lang.Runnable;
import java.util.Arrays;
import java.util.LinkedList;
import java.util.UUID;
class HIDDeviceBLESteamController extends BluetoothGattCallback implements HIDDevice {
private static final String TAG = "hidapi";
private HIDDeviceManager mManager;
private BluetoothDevice mDevice;
private int mDeviceId;
private BluetoothGatt mGatt;
private boolean mIsRegistered = false;
private boolean mIsConnected = false;
private boolean mIsChromebook = false;
private boolean mIsReconnecting = false;
private boolean mFrozen = false;
private LinkedList<GattOperation> mOperations;
GattOperation mCurrentOperation = null;
private Handler mHandler;
private static final int TRANSPORT_AUTO = 0;
private static final int TRANSPORT_BREDR = 1;
private static final int TRANSPORT_LE = 2;
private static final int CHROMEBOOK_CONNECTION_CHECK_INTERVAL = 10000;
static public final UUID steamControllerService = UUID.fromString("100F6C32-1735-4313-B402-38567131E5F3");
static public final UUID inputCharacteristic = UUID.fromString("100F6C33-1735-4313-B402-38567131E5F3");
static public final UUID reportCharacteristic = UUID.fromString("100F6C34-1735-4313-B402-38567131E5F3");
static private final byte[] enterValveMode = new byte[] { (byte)0xC0, (byte)0x87, 0x03, 0x08, 0x07, 0x00 };
static class GattOperation {
private enum Operation {
CHR_READ,
CHR_WRITE,
ENABLE_NOTIFICATION
}
Operation mOp;
UUID mUuid;
byte[] mValue;
BluetoothGatt mGatt;
boolean mResult = true;
private GattOperation(BluetoothGatt gatt, GattOperation.Operation operation, UUID uuid) {
mGatt = gatt;
mOp = operation;
mUuid = uuid;
}
private GattOperation(BluetoothGatt gatt, GattOperation.Operation operation, UUID uuid, byte[] value) {
mGatt = gatt;
mOp = operation;
mUuid = uuid;
mValue = value;
}
public void run() {
// This is executed in main thread
BluetoothGattCharacteristic chr;
switch (mOp) {
case CHR_READ:
chr = getCharacteristic(mUuid);
//Log.v(TAG, "Reading characteristic " + chr.getUuid());
if (!mGatt.readCharacteristic(chr)) {
Log.e(TAG, "Unable to read characteristic " + mUuid.toString());
mResult = false;
break;
}
mResult = true;
break;
case CHR_WRITE:
chr = getCharacteristic(mUuid);
//Log.v(TAG, "Writing characteristic " + chr.getUuid() + " value=" + HexDump.toHexString(value));
chr.setValue(mValue);
if (!mGatt.writeCharacteristic(chr)) {
Log.e(TAG, "Unable to write characteristic " + mUuid.toString());
mResult = false;
break;
}
mResult = true;
break;
case ENABLE_NOTIFICATION:
chr = getCharacteristic(mUuid);
//Log.v(TAG, "Writing descriptor of " + chr.getUuid());
if (chr != null) {
BluetoothGattDescriptor cccd = chr.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"));
if (cccd != null) {
int properties = chr.getProperties();
byte[] value;
if ((properties & BluetoothGattCharacteristic.PROPERTY_NOTIFY) == BluetoothGattCharacteristic.PROPERTY_NOTIFY) {
value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE;
} else if ((properties & BluetoothGattCharacteristic.PROPERTY_INDICATE) == BluetoothGattCharacteristic.PROPERTY_INDICATE) {
value = BluetoothGattDescriptor.ENABLE_INDICATION_VALUE;
} else {
Log.e(TAG, "Unable to start notifications on input characteristic");
mResult = false;
return;
}
mGatt.setCharacteristicNotification(chr, true);
cccd.setValue(value);
if (!mGatt.writeDescriptor(cccd)) {
Log.e(TAG, "Unable to write descriptor " + mUuid.toString());
mResult = false;
return;
}
mResult = true;
}
}
}
}
public boolean finish() {
return mResult;
}
private BluetoothGattCharacteristic getCharacteristic(UUID uuid) {
BluetoothGattService valveService = mGatt.getService(steamControllerService);
if (valveService == null)
return null;
return valveService.getCharacteristic(uuid);
}
static public GattOperation readCharacteristic(BluetoothGatt gatt, UUID uuid) {
return new GattOperation(gatt, Operation.CHR_READ, uuid);
}
static public GattOperation writeCharacteristic(BluetoothGatt gatt, UUID uuid, byte[] value) {
return new GattOperation(gatt, Operation.CHR_WRITE, uuid, value);
}
static public GattOperation enableNotification(BluetoothGatt gatt, UUID uuid) {
return new GattOperation(gatt, Operation.ENABLE_NOTIFICATION, uuid);
}
}
public HIDDeviceBLESteamController(HIDDeviceManager manager, BluetoothDevice device) {
mManager = manager;
mDevice = device;
mDeviceId = mManager.getDeviceIDForIdentifier(getIdentifier());
mIsRegistered = false;
mIsChromebook = mManager.getContext().getPackageManager().hasSystemFeature("org.chromium.arc.device_management");
mOperations = new LinkedList<GattOperation>();
mHandler = new Handler(Looper.getMainLooper());
mGatt = connectGatt();
// final HIDDeviceBLESteamController finalThis = this;
// mHandler.postDelayed(new Runnable() {
// @Override
// public void run() {
// finalThis.checkConnectionForChromebookIssue();
// }
// }, CHROMEBOOK_CONNECTION_CHECK_INTERVAL);
}
public String getIdentifier() {
return String.format("SteamController.%s", mDevice.getAddress());
}
public BluetoothGatt getGatt() {
return mGatt;
}
// Because on Chromebooks we show up as a dual-mode device, it will attempt to connect TRANSPORT_AUTO, which will use TRANSPORT_BREDR instead
// of TRANSPORT_LE. Let's force ourselves to connect low energy.
private BluetoothGatt connectGatt(boolean managed) {
if (Build.VERSION.SDK_INT >= 23) {
try {
return mDevice.connectGatt(mManager.getContext(), managed, this, TRANSPORT_LE);
} catch (Exception e) {
return mDevice.connectGatt(mManager.getContext(), managed, this);
}
} else {
return mDevice.connectGatt(mManager.getContext(), managed, this);
}
}
private BluetoothGatt connectGatt() {
return connectGatt(false);
}
protected int getConnectionState() {
Context context = mManager.getContext();
if (context == null) {
// We are lacking any context to get our Bluetooth information. We'll just assume disconnected.
return BluetoothProfile.STATE_DISCONNECTED;
}
BluetoothManager btManager = (BluetoothManager)context.getSystemService(Context.BLUETOOTH_SERVICE);
if (btManager == null) {
// This device doesn't support Bluetooth. We should never be here, because how did
// we instantiate a device to start with?
return BluetoothProfile.STATE_DISCONNECTED;
}
return btManager.getConnectionState(mDevice, BluetoothProfile.GATT);
}
public void reconnect() {
if (getConnectionState() != BluetoothProfile.STATE_CONNECTED) {
mGatt.disconnect();
mGatt = connectGatt();
}
}
protected void checkConnectionForChromebookIssue() {
if (!mIsChromebook) {
// We only do this on Chromebooks, because otherwise it's really annoying to just attempt
// over and over.
return;
}
int connectionState = getConnectionState();
switch (connectionState) {
case BluetoothProfile.STATE_CONNECTED:
if (!mIsConnected) {
// We are in the Bad Chromebook Place. We can force a disconnect
// to try to recover.
Log.v(TAG, "Chromebook: We are in a very bad state; the controller shows as connected in the underlying Bluetooth layer, but we never received a callback. Forcing a reconnect.");
mIsReconnecting = true;
mGatt.disconnect();
mGatt = connectGatt(false);
break;
}
else if (!isRegistered()) {
if (mGatt.getServices().size() > 0) {
Log.v(TAG, "Chromebook: We are connected to a controller, but never got our registration. Trying to recover.");
probeService(this);
}
else {
Log.v(TAG, "Chromebook: We are connected to a controller, but never discovered services. Trying to recover.");
mIsReconnecting = true;
mGatt.disconnect();
mGatt = connectGatt(false);
break;
}
}
else {
Log.v(TAG, "Chromebook: We are connected, and registered. Everything's good!");
return;
}
break;
case BluetoothProfile.STATE_DISCONNECTED:
Log.v(TAG, "Chromebook: We have either been disconnected, or the Chromebook BtGatt.ContextMap bug has bitten us. Attempting a disconnect/reconnect, but we may not be able to recover.");
mIsReconnecting = true;
mGatt.disconnect();
mGatt = connectGatt(false);
break;
case BluetoothProfile.STATE_CONNECTING:
Log.v(TAG, "Chromebook: We're still trying to connect. Waiting a bit longer.");
break;
}
final HIDDeviceBLESteamController finalThis = this;
mHandler.postDelayed(new Runnable() {
@Override
public void run() {
finalThis.checkConnectionForChromebookIssue();
}
}, CHROMEBOOK_CONNECTION_CHECK_INTERVAL);
}
private boolean isRegistered() {
return mIsRegistered;
}
private void setRegistered() {
mIsRegistered = true;
}
private boolean probeService(HIDDeviceBLESteamController controller) {
if (isRegistered()) {
return true;
}
if (!mIsConnected) {
return false;
}
Log.v(TAG, "probeService controller=" + controller);
for (BluetoothGattService service : mGatt.getServices()) {
if (service.getUuid().equals(steamControllerService)) {
Log.v(TAG, "Found Valve steam controller service " + service.getUuid());
for (BluetoothGattCharacteristic chr : service.getCharacteristics()) {
if (chr.getUuid().equals(inputCharacteristic)) {
Log.v(TAG, "Found input characteristic");
// Start notifications
BluetoothGattDescriptor cccd = chr.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"));
if (cccd != null) {
enableNotification(chr.getUuid());
}
}
}
return true;
}
}
if ((mGatt.getServices().size() == 0) && mIsChromebook && !mIsReconnecting) {
Log.e(TAG, "Chromebook: Discovered services were empty; this almost certainly means the BtGatt.ContextMap bug has bitten us.");
mIsConnected = false;
mIsReconnecting = true;
mGatt.disconnect();
mGatt = connectGatt(false);
}
return false;
}
//////////////////////////////////////////////////////////////////////////////////////////////////////
//////////////////////////////////////////////////////////////////////////////////////////////////////
//////////////////////////////////////////////////////////////////////////////////////////////////////
private void finishCurrentGattOperation() {
GattOperation op = null;
synchronized (mOperations) {
if (mCurrentOperation != null) {
op = mCurrentOperation;
mCurrentOperation = null;
}
}
if (op != null) {
boolean result = op.finish(); // TODO: Maybe in main thread as well?
// Our operation failed, let's add it back to the beginning of our queue.
if (!result) {
mOperations.addFirst(op);
}
}
executeNextGattOperation();
}
private void executeNextGattOperation() {
synchronized (mOperations) {
if (mCurrentOperation != null)
return;
if (mOperations.isEmpty())
return;
mCurrentOperation = mOperations.removeFirst();
}
// Run in main thread
mHandler.post(new Runnable() {
@Override
public void run() {
synchronized (mOperations) {
if (mCurrentOperation == null) {
Log.e(TAG, "Current operation null in executor?");
return;
}
mCurrentOperation.run();
// now wait for the GATT callback and when it comes, finish this operation
}
}
});
}
private void queueGattOperation(GattOperation op) {
synchronized (mOperations) {
mOperations.add(op);
}
executeNextGattOperation();
}
private void enableNotification(UUID chrUuid) {
GattOperation op = HIDDeviceBLESteamController.GattOperation.enableNotification(mGatt, chrUuid);
queueGattOperation(op);
}
public void writeCharacteristic(UUID uuid, byte[] value) {
GattOperation op = HIDDeviceBLESteamController.GattOperation.writeCharacteristic(mGatt, uuid, value);
queueGattOperation(op);
}
public void readCharacteristic(UUID uuid) {
GattOperation op = HIDDeviceBLESteamController.GattOperation.readCharacteristic(mGatt, uuid);
queueGattOperation(op);
}
//////////////////////////////////////////////////////////////////////////////////////////////////////
////////////// BluetoothGattCallback overridden methods
//////////////////////////////////////////////////////////////////////////////////////////////////////
public void onConnectionStateChange(BluetoothGatt g, int status, int newState) {
//Log.v(TAG, "onConnectionStateChange status=" + status + " newState=" + newState);
mIsReconnecting = false;
if (newState == 2) {
mIsConnected = true;
// Run directly, without GattOperation
if (!isRegistered()) {
mHandler.post(new Runnable() {
@Override
public void run() {
mGatt.discoverServices();
}
});
}
}
else if (newState == 0) {
mIsConnected = false;
}
// Disconnection is handled in SteamLink using the ACTION_ACL_DISCONNECTED Intent.
}
public void onServicesDiscovered(BluetoothGatt gatt, int status) {
//Log.v(TAG, "onServicesDiscovered status=" + status);
if (status == 0) {
if (gatt.getServices().size() == 0) {
Log.v(TAG, "onServicesDiscovered returned zero services; something has gone horribly wrong down in Android's Bluetooth stack.");
mIsReconnecting = true;
mIsConnected = false;
gatt.disconnect();
mGatt = connectGatt(false);
}
else {
probeService(this);
}
}
}
public void onCharacteristicRead(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) {
//Log.v(TAG, "onCharacteristicRead status=" + status + " uuid=" + characteristic.getUuid());
if (characteristic.getUuid().equals(reportCharacteristic) && !mFrozen) {
mManager.HIDDeviceFeatureReport(getId(), characteristic.getValue());
}
finishCurrentGattOperation();
}
public void onCharacteristicWrite(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) {
//Log.v(TAG, "onCharacteristicWrite status=" + status + " uuid=" + characteristic.getUuid());
if (characteristic.getUuid().equals(reportCharacteristic)) {
// Only register controller with the native side once it has been fully configured
if (!isRegistered()) {
Log.v(TAG, "Registering Steam Controller with ID: " + getId());
mManager.HIDDeviceConnected(getId(), getIdentifier(), getVendorId(), getProductId(), getSerialNumber(), getVersion(), getManufacturerName(), getProductName(), 0, 0, 0, 0);
setRegistered();
}
}
finishCurrentGattOperation();
}
public void onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) {
// Enable this for verbose logging of controller input reports
//Log.v(TAG, "onCharacteristicChanged uuid=" + characteristic.getUuid() + " data=" + HexDump.dumpHexString(characteristic.getValue()));
if (characteristic.getUuid().equals(inputCharacteristic) && !mFrozen) {
mManager.HIDDeviceInputReport(getId(), characteristic.getValue());
}
}
public void onDescriptorRead(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) {
//Log.v(TAG, "onDescriptorRead status=" + status);
}
public void onDescriptorWrite(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) {
BluetoothGattCharacteristic chr = descriptor.getCharacteristic();
//Log.v(TAG, "onDescriptorWrite status=" + status + " uuid=" + chr.getUuid() + " descriptor=" + descriptor.getUuid());
if (chr.getUuid().equals(inputCharacteristic)) {
boolean hasWrittenInputDescriptor = true;
BluetoothGattCharacteristic reportChr = chr.getService().getCharacteristic(reportCharacteristic);
if (reportChr != null) {
Log.v(TAG, "Writing report characteristic to enter valve mode");
reportChr.setValue(enterValveMode);
gatt.writeCharacteristic(reportChr);
}
}
finishCurrentGattOperation();
}
public void onReliableWriteCompleted(BluetoothGatt gatt, int status) {
//Log.v(TAG, "onReliableWriteCompleted status=" + status);
}
public void onReadRemoteRssi(BluetoothGatt gatt, int rssi, int status) {
//Log.v(TAG, "onReadRemoteRssi status=" + status);
}
public void onMtuChanged(BluetoothGatt gatt, int mtu, int status) {
//Log.v(TAG, "onMtuChanged status=" + status);
}
//////////////////////////////////////////////////////////////////////////////////////////////////////
//////// Public API
//////////////////////////////////////////////////////////////////////////////////////////////////////
@Override
public int getId() {
return mDeviceId;
}
@Override
public int getVendorId() {
// Valve Corporation
final int VALVE_USB_VID = 0x28DE;
return VALVE_USB_VID;
}
@Override
public int getProductId() {
// We don't have an easy way to query from the Bluetooth device, but we know what it is
final int D0G_BLE2_PID = 0x1106;
return D0G_BLE2_PID;
}
@Override
public String getSerialNumber() {
// This will be read later via feature report by Steam
return "12345";
}
@Override
public int getVersion() {
return 0;
}
@Override
public String getManufacturerName() {
return "Valve Corporation";
}
@Override
public String getProductName() {
return "Steam Controller";
}
@Override
public UsbDevice getDevice() {
return null;
}
@Override
public boolean open() {
return true;
}
@Override
public int sendFeatureReport(byte[] report) {
if (!isRegistered()) {
Log.e(TAG, "Attempted sendFeatureReport before Steam Controller is registered!");
if (mIsConnected) {
probeService(this);
}
return -1;
}
// We need to skip the first byte, as that doesn't go over the air
byte[] actual_report = Arrays.copyOfRange(report, 1, report.length - 1);
//Log.v(TAG, "sendFeatureReport " + HexDump.dumpHexString(actual_report));
writeCharacteristic(reportCharacteristic, actual_report);
return report.length;
}
@Override
public int sendOutputReport(byte[] report) {
if (!isRegistered()) {
Log.e(TAG, "Attempted sendOutputReport before Steam Controller is registered!");
if (mIsConnected) {
probeService(this);
}
return -1;
}
//Log.v(TAG, "sendFeatureReport " + HexDump.dumpHexString(report));
writeCharacteristic(reportCharacteristic, report);
return report.length;
}
@Override
public boolean getFeatureReport(byte[] report) {
if (!isRegistered()) {
Log.e(TAG, "Attempted getFeatureReport before Steam Controller is registered!");
if (mIsConnected) {
probeService(this);
}
return false;
}
//Log.v(TAG, "getFeatureReport");
readCharacteristic(reportCharacteristic);
return true;
}
@Override
public void close() {
}
@Override
public void setFrozen(boolean frozen) {
mFrozen = frozen;
}
@Override
public void shutdown() {
close();
BluetoothGatt g = mGatt;
if (g != null) {
g.disconnect();
g.close();
mGatt = null;
}
mManager = null;
mIsRegistered = false;
mIsConnected = false;
mOperations.clear();
}
}

View File

@ -0,0 +1,679 @@
package org.libsdl.app;
import android.app.Activity;
import android.app.AlertDialog;
import android.app.PendingIntent;
import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothManager;
import android.bluetooth.BluetoothProfile;
import android.os.Build;
import android.util.Log;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.SharedPreferences;
import android.content.pm.PackageManager;
import android.hardware.usb.*;
import android.os.Handler;
import android.os.Looper;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
public class HIDDeviceManager {
private static final String TAG = "hidapi";
private static final String ACTION_USB_PERMISSION = "org.libsdl.app.USB_PERMISSION";
private static HIDDeviceManager sManager;
private static int sManagerRefCount = 0;
public static HIDDeviceManager acquire(Context context) {
if (sManagerRefCount == 0) {
sManager = new HIDDeviceManager(context);
}
++sManagerRefCount;
return sManager;
}
public static void release(HIDDeviceManager manager) {
if (manager == sManager) {
--sManagerRefCount;
if (sManagerRefCount == 0) {
sManager.close();
sManager = null;
}
}
}
private Context mContext;
private HashMap<Integer, HIDDevice> mDevicesById = new HashMap<Integer, HIDDevice>();
private HashMap<BluetoothDevice, HIDDeviceBLESteamController> mBluetoothDevices = new HashMap<BluetoothDevice, HIDDeviceBLESteamController>();
private int mNextDeviceId = 0;
private SharedPreferences mSharedPreferences = null;
private boolean mIsChromebook = false;
private UsbManager mUsbManager;
private Handler mHandler;
private BluetoothManager mBluetoothManager;
private List<BluetoothDevice> mLastBluetoothDevices;
private final BroadcastReceiver mUsbBroadcast = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
String action = intent.getAction();
if (action.equals(UsbManager.ACTION_USB_DEVICE_ATTACHED)) {
UsbDevice usbDevice = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE);
handleUsbDeviceAttached(usbDevice);
} else if (action.equals(UsbManager.ACTION_USB_DEVICE_DETACHED)) {
UsbDevice usbDevice = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE);
handleUsbDeviceDetached(usbDevice);
} else if (action.equals(HIDDeviceManager.ACTION_USB_PERMISSION)) {
UsbDevice usbDevice = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE);
handleUsbDevicePermission(usbDevice, intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false));
}
}
};
private final BroadcastReceiver mBluetoothBroadcast = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
String action = intent.getAction();
// Bluetooth device was connected. If it was a Steam Controller, handle it
if (action.equals(BluetoothDevice.ACTION_ACL_CONNECTED)) {
BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE);
Log.d(TAG, "Bluetooth device connected: " + device);
if (isSteamController(device)) {
connectBluetoothDevice(device);
}
}
// Bluetooth device was disconnected, remove from controller manager (if any)
if (action.equals(BluetoothDevice.ACTION_ACL_DISCONNECTED)) {
BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE);
Log.d(TAG, "Bluetooth device disconnected: " + device);
disconnectBluetoothDevice(device);
}
}
};
private HIDDeviceManager(final Context context) {
mContext = context;
HIDDeviceRegisterCallback();
mSharedPreferences = mContext.getSharedPreferences("hidapi", Context.MODE_PRIVATE);
mIsChromebook = mContext.getPackageManager().hasSystemFeature("org.chromium.arc.device_management");
// if (shouldClear) {
// SharedPreferences.Editor spedit = mSharedPreferences.edit();
// spedit.clear();
// spedit.commit();
// }
// else
{
mNextDeviceId = mSharedPreferences.getInt("next_device_id", 0);
}
}
public Context getContext() {
return mContext;
}
public int getDeviceIDForIdentifier(String identifier) {
SharedPreferences.Editor spedit = mSharedPreferences.edit();
int result = mSharedPreferences.getInt(identifier, 0);
if (result == 0) {
result = mNextDeviceId++;
spedit.putInt("next_device_id", mNextDeviceId);
}
spedit.putInt(identifier, result);
spedit.commit();
return result;
}
private void initializeUSB() {
mUsbManager = (UsbManager)mContext.getSystemService(Context.USB_SERVICE);
if (mUsbManager == null) {
return;
}
/*
// Logging
for (UsbDevice device : mUsbManager.getDeviceList().values()) {
Log.i(TAG,"Path: " + device.getDeviceName());
Log.i(TAG,"Manufacturer: " + device.getManufacturerName());
Log.i(TAG,"Product: " + device.getProductName());
Log.i(TAG,"ID: " + device.getDeviceId());
Log.i(TAG,"Class: " + device.getDeviceClass());
Log.i(TAG,"Protocol: " + device.getDeviceProtocol());
Log.i(TAG,"Vendor ID " + device.getVendorId());
Log.i(TAG,"Product ID: " + device.getProductId());
Log.i(TAG,"Interface count: " + device.getInterfaceCount());
Log.i(TAG,"---------------------------------------");
// Get interface details
for (int index = 0; index < device.getInterfaceCount(); index++) {
UsbInterface mUsbInterface = device.getInterface(index);
Log.i(TAG," ***** *****");
Log.i(TAG," Interface index: " + index);
Log.i(TAG," Interface ID: " + mUsbInterface.getId());
Log.i(TAG," Interface class: " + mUsbInterface.getInterfaceClass());
Log.i(TAG," Interface subclass: " + mUsbInterface.getInterfaceSubclass());
Log.i(TAG," Interface protocol: " + mUsbInterface.getInterfaceProtocol());
Log.i(TAG," Endpoint count: " + mUsbInterface.getEndpointCount());
// Get endpoint details
for (int epi = 0; epi < mUsbInterface.getEndpointCount(); epi++)
{
UsbEndpoint mEndpoint = mUsbInterface.getEndpoint(epi);
Log.i(TAG," ++++ ++++ ++++");
Log.i(TAG," Endpoint index: " + epi);
Log.i(TAG," Attributes: " + mEndpoint.getAttributes());
Log.i(TAG," Direction: " + mEndpoint.getDirection());
Log.i(TAG," Number: " + mEndpoint.getEndpointNumber());
Log.i(TAG," Interval: " + mEndpoint.getInterval());
Log.i(TAG," Packet size: " + mEndpoint.getMaxPacketSize());
Log.i(TAG," Type: " + mEndpoint.getType());
}
}
}
Log.i(TAG," No more devices connected.");
*/
// Register for USB broadcasts and permission completions
IntentFilter filter = new IntentFilter();
filter.addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED);
filter.addAction(UsbManager.ACTION_USB_DEVICE_DETACHED);
filter.addAction(HIDDeviceManager.ACTION_USB_PERMISSION);
mContext.registerReceiver(mUsbBroadcast, filter);
for (UsbDevice usbDevice : mUsbManager.getDeviceList().values()) {
handleUsbDeviceAttached(usbDevice);
}
}
UsbManager getUSBManager() {
return mUsbManager;
}
private void shutdownUSB() {
try {
mContext.unregisterReceiver(mUsbBroadcast);
} catch (Exception e) {
// We may not have registered, that's okay
}
}
private boolean isHIDDeviceInterface(UsbDevice usbDevice, UsbInterface usbInterface) {
if (usbInterface.getInterfaceClass() == UsbConstants.USB_CLASS_HID) {
return true;
}
if (isXbox360Controller(usbDevice, usbInterface) || isXboxOneController(usbDevice, usbInterface)) {
return true;
}
return false;
}
private boolean isXbox360Controller(UsbDevice usbDevice, UsbInterface usbInterface) {
final int XB360_IFACE_SUBCLASS = 93;
final int XB360_IFACE_PROTOCOL = 1; // Wired
final int XB360W_IFACE_PROTOCOL = 129; // Wireless
final int[] SUPPORTED_VENDORS = {
0x0079, // GPD Win 2
0x044f, // Thrustmaster
0x045e, // Microsoft
0x046d, // Logitech
0x056e, // Elecom
0x06a3, // Saitek
0x0738, // Mad Catz
0x07ff, // Mad Catz
0x0e6f, // PDP
0x0f0d, // Hori
0x1038, // SteelSeries
0x11c9, // Nacon
0x12ab, // Unknown
0x1430, // RedOctane
0x146b, // BigBen
0x1532, // Razer Sabertooth
0x15e4, // Numark
0x162e, // Joytech
0x1689, // Razer Onza
0x1949, // Lab126, Inc.
0x1bad, // Harmonix
0x20d6, // PowerA
0x24c6, // PowerA
0x2c22, // Qanba
};
if (usbInterface.getInterfaceClass() == UsbConstants.USB_CLASS_VENDOR_SPEC &&
usbInterface.getInterfaceSubclass() == XB360_IFACE_SUBCLASS &&
(usbInterface.getInterfaceProtocol() == XB360_IFACE_PROTOCOL ||
usbInterface.getInterfaceProtocol() == XB360W_IFACE_PROTOCOL)) {
int vendor_id = usbDevice.getVendorId();
for (int supportedVid : SUPPORTED_VENDORS) {
if (vendor_id == supportedVid) {
return true;
}
}
}
return false;
}
private boolean isXboxOneController(UsbDevice usbDevice, UsbInterface usbInterface) {
final int XB1_IFACE_SUBCLASS = 71;
final int XB1_IFACE_PROTOCOL = 208;
final int[] SUPPORTED_VENDORS = {
0x045e, // Microsoft
0x0738, // Mad Catz
0x0e6f, // PDP
0x0f0d, // Hori
0x1532, // Razer Wildcat
0x20d6, // PowerA
0x24c6, // PowerA
0x2dc8, /* 8BitDo */
0x2e24, // Hyperkin
};
if (usbInterface.getId() == 0 &&
usbInterface.getInterfaceClass() == UsbConstants.USB_CLASS_VENDOR_SPEC &&
usbInterface.getInterfaceSubclass() == XB1_IFACE_SUBCLASS &&
usbInterface.getInterfaceProtocol() == XB1_IFACE_PROTOCOL) {
int vendor_id = usbDevice.getVendorId();
for (int supportedVid : SUPPORTED_VENDORS) {
if (vendor_id == supportedVid) {
return true;
}
}
}
return false;
}
private void handleUsbDeviceAttached(UsbDevice usbDevice) {
connectHIDDeviceUSB(usbDevice);
}
private void handleUsbDeviceDetached(UsbDevice usbDevice) {
List<Integer> devices = new ArrayList<Integer>();
for (HIDDevice device : mDevicesById.values()) {
if (usbDevice.equals(device.getDevice())) {
devices.add(device.getId());
}
}
for (int id : devices) {
HIDDevice device = mDevicesById.get(id);
mDevicesById.remove(id);
device.shutdown();
HIDDeviceDisconnected(id);
}
}
private void handleUsbDevicePermission(UsbDevice usbDevice, boolean permission_granted) {
for (HIDDevice device : mDevicesById.values()) {
if (usbDevice.equals(device.getDevice())) {
boolean opened = false;
if (permission_granted) {
opened = device.open();
}
HIDDeviceOpenResult(device.getId(), opened);
}
}
}
private void connectHIDDeviceUSB(UsbDevice usbDevice) {
synchronized (this) {
int interface_mask = 0;
for (int interface_index = 0; interface_index < usbDevice.getInterfaceCount(); interface_index++) {
UsbInterface usbInterface = usbDevice.getInterface(interface_index);
if (isHIDDeviceInterface(usbDevice, usbInterface)) {
// Check to see if we've already added this interface
// This happens with the Xbox Series X controller which has a duplicate interface 0, which is inactive
int interface_id = usbInterface.getId();
if ((interface_mask & (1 << interface_id)) != 0) {
continue;
}
interface_mask |= (1 << interface_id);
HIDDeviceUSB device = new HIDDeviceUSB(this, usbDevice, interface_index);
int id = device.getId();
mDevicesById.put(id, device);
HIDDeviceConnected(id, device.getIdentifier(), device.getVendorId(), device.getProductId(), device.getSerialNumber(), device.getVersion(), device.getManufacturerName(), device.getProductName(), usbInterface.getId(), usbInterface.getInterfaceClass(), usbInterface.getInterfaceSubclass(), usbInterface.getInterfaceProtocol());
}
}
}
}
private void initializeBluetooth() {
Log.d(TAG, "Initializing Bluetooth");
if (Build.VERSION.SDK_INT <= 30 &&
mContext.getPackageManager().checkPermission(android.Manifest.permission.BLUETOOTH, mContext.getPackageName()) != PackageManager.PERMISSION_GRANTED) {
Log.d(TAG, "Couldn't initialize Bluetooth, missing android.permission.BLUETOOTH");
return;
}
if (!mContext.getPackageManager().hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE) || (Build.VERSION.SDK_INT < 18)) {
Log.d(TAG, "Couldn't initialize Bluetooth, this version of Android does not support Bluetooth LE");
return;
}
// Find bonded bluetooth controllers and create SteamControllers for them
mBluetoothManager = (BluetoothManager)mContext.getSystemService(Context.BLUETOOTH_SERVICE);
if (mBluetoothManager == null) {
// This device doesn't support Bluetooth.
return;
}
BluetoothAdapter btAdapter = mBluetoothManager.getAdapter();
if (btAdapter == null) {
// This device has Bluetooth support in the codebase, but has no available adapters.
return;
}
// Get our bonded devices.
for (BluetoothDevice device : btAdapter.getBondedDevices()) {
Log.d(TAG, "Bluetooth device available: " + device);
if (isSteamController(device)) {
connectBluetoothDevice(device);
}
}
// NOTE: These don't work on Chromebooks, to my undying dismay.
IntentFilter filter = new IntentFilter();
filter.addAction(BluetoothDevice.ACTION_ACL_CONNECTED);
filter.addAction(BluetoothDevice.ACTION_ACL_DISCONNECTED);
mContext.registerReceiver(mBluetoothBroadcast, filter);
if (mIsChromebook) {
mHandler = new Handler(Looper.getMainLooper());
mLastBluetoothDevices = new ArrayList<BluetoothDevice>();
// final HIDDeviceManager finalThis = this;
// mHandler.postDelayed(new Runnable() {
// @Override
// public void run() {
// finalThis.chromebookConnectionHandler();
// }
// }, 5000);
}
}
private void shutdownBluetooth() {
try {
mContext.unregisterReceiver(mBluetoothBroadcast);
} catch (Exception e) {
// We may not have registered, that's okay
}
}
// Chromebooks do not pass along ACTION_ACL_CONNECTED / ACTION_ACL_DISCONNECTED properly.
// This function provides a sort of dummy version of that, watching for changes in the
// connected devices and attempting to add controllers as things change.
public void chromebookConnectionHandler() {
if (!mIsChromebook) {
return;
}
ArrayList<BluetoothDevice> disconnected = new ArrayList<BluetoothDevice>();
ArrayList<BluetoothDevice> connected = new ArrayList<BluetoothDevice>();
List<BluetoothDevice> currentConnected = mBluetoothManager.getConnectedDevices(BluetoothProfile.GATT);
for (BluetoothDevice bluetoothDevice : currentConnected) {
if (!mLastBluetoothDevices.contains(bluetoothDevice)) {
connected.add(bluetoothDevice);
}
}
for (BluetoothDevice bluetoothDevice : mLastBluetoothDevices) {
if (!currentConnected.contains(bluetoothDevice)) {
disconnected.add(bluetoothDevice);
}
}
mLastBluetoothDevices = currentConnected;
for (BluetoothDevice bluetoothDevice : disconnected) {
disconnectBluetoothDevice(bluetoothDevice);
}
for (BluetoothDevice bluetoothDevice : connected) {
connectBluetoothDevice(bluetoothDevice);
}
final HIDDeviceManager finalThis = this;
mHandler.postDelayed(new Runnable() {
@Override
public void run() {
finalThis.chromebookConnectionHandler();
}
}, 10000);
}
public boolean connectBluetoothDevice(BluetoothDevice bluetoothDevice) {
Log.v(TAG, "connectBluetoothDevice device=" + bluetoothDevice);
synchronized (this) {
if (mBluetoothDevices.containsKey(bluetoothDevice)) {
Log.v(TAG, "Steam controller with address " + bluetoothDevice + " already exists, attempting reconnect");
HIDDeviceBLESteamController device = mBluetoothDevices.get(bluetoothDevice);
device.reconnect();
return false;
}
HIDDeviceBLESteamController device = new HIDDeviceBLESteamController(this, bluetoothDevice);
int id = device.getId();
mBluetoothDevices.put(bluetoothDevice, device);
mDevicesById.put(id, device);
// The Steam Controller will mark itself connected once initialization is complete
}
return true;
}
public void disconnectBluetoothDevice(BluetoothDevice bluetoothDevice) {
synchronized (this) {
HIDDeviceBLESteamController device = mBluetoothDevices.get(bluetoothDevice);
if (device == null)
return;
int id = device.getId();
mBluetoothDevices.remove(bluetoothDevice);
mDevicesById.remove(id);
device.shutdown();
HIDDeviceDisconnected(id);
}
}
public boolean isSteamController(BluetoothDevice bluetoothDevice) {
// Sanity check. If you pass in a null device, by definition it is never a Steam Controller.
if (bluetoothDevice == null) {
return false;
}
// If the device has no local name, we really don't want to try an equality check against it.
if (bluetoothDevice.getName() == null) {
return false;
}
return bluetoothDevice.getName().equals("SteamController") && ((bluetoothDevice.getType() & BluetoothDevice.DEVICE_TYPE_LE) != 0);
}
private void close() {
shutdownUSB();
shutdownBluetooth();
synchronized (this) {
for (HIDDevice device : mDevicesById.values()) {
device.shutdown();
}
mDevicesById.clear();
mBluetoothDevices.clear();
HIDDeviceReleaseCallback();
}
}
public void setFrozen(boolean frozen) {
synchronized (this) {
for (HIDDevice device : mDevicesById.values()) {
device.setFrozen(frozen);
}
}
}
//////////////////////////////////////////////////////////////////////////////////////////////////////
//////////////////////////////////////////////////////////////////////////////////////////////////////
//////////////////////////////////////////////////////////////////////////////////////////////////////
private HIDDevice getDevice(int id) {
synchronized (this) {
HIDDevice result = mDevicesById.get(id);
if (result == null) {
Log.v(TAG, "No device for id: " + id);
Log.v(TAG, "Available devices: " + mDevicesById.keySet());
}
return result;
}
}
//////////////////////////////////////////////////////////////////////////////////////////////////////
////////// JNI interface functions
//////////////////////////////////////////////////////////////////////////////////////////////////////
public boolean initialize(boolean usb, boolean bluetooth) {
Log.v(TAG, "initialize(" + usb + ", " + bluetooth + ")");
if (usb) {
initializeUSB();
}
if (bluetooth) {
initializeBluetooth();
}
return true;
}
public boolean openDevice(int deviceID) {
Log.v(TAG, "openDevice deviceID=" + deviceID);
HIDDevice device = getDevice(deviceID);
if (device == null) {
HIDDeviceDisconnected(deviceID);
return false;
}
// Look to see if this is a USB device and we have permission to access it
UsbDevice usbDevice = device.getDevice();
if (usbDevice != null && !mUsbManager.hasPermission(usbDevice)) {
HIDDeviceOpenPending(deviceID);
try {
final int FLAG_MUTABLE = 0x02000000; // PendingIntent.FLAG_MUTABLE, but don't require SDK 31
int flags;
if (Build.VERSION.SDK_INT >= 31) {
flags = FLAG_MUTABLE;
} else {
flags = 0;
}
mUsbManager.requestPermission(usbDevice, PendingIntent.getBroadcast(mContext, 0, new Intent(HIDDeviceManager.ACTION_USB_PERMISSION), flags));
} catch (Exception e) {
Log.v(TAG, "Couldn't request permission for USB device " + usbDevice);
HIDDeviceOpenResult(deviceID, false);
}
return false;
}
try {
return device.open();
} catch (Exception e) {
Log.e(TAG, "Got exception: " + Log.getStackTraceString(e));
}
return false;
}
public int sendOutputReport(int deviceID, byte[] report) {
try {
//Log.v(TAG, "sendOutputReport deviceID=" + deviceID + " length=" + report.length);
HIDDevice device;
device = getDevice(deviceID);
if (device == null) {
HIDDeviceDisconnected(deviceID);
return -1;
}
return device.sendOutputReport(report);
} catch (Exception e) {
Log.e(TAG, "Got exception: " + Log.getStackTraceString(e));
}
return -1;
}
public int sendFeatureReport(int deviceID, byte[] report) {
try {
//Log.v(TAG, "sendFeatureReport deviceID=" + deviceID + " length=" + report.length);
HIDDevice device;
device = getDevice(deviceID);
if (device == null) {
HIDDeviceDisconnected(deviceID);
return -1;
}
return device.sendFeatureReport(report);
} catch (Exception e) {
Log.e(TAG, "Got exception: " + Log.getStackTraceString(e));
}
return -1;
}
public boolean getFeatureReport(int deviceID, byte[] report) {
try {
//Log.v(TAG, "getFeatureReport deviceID=" + deviceID);
HIDDevice device;
device = getDevice(deviceID);
if (device == null) {
HIDDeviceDisconnected(deviceID);
return false;
}
return device.getFeatureReport(report);
} catch (Exception e) {
Log.e(TAG, "Got exception: " + Log.getStackTraceString(e));
}
return false;
}
public void closeDevice(int deviceID) {
try {
Log.v(TAG, "closeDevice deviceID=" + deviceID);
HIDDevice device;
device = getDevice(deviceID);
if (device == null) {
HIDDeviceDisconnected(deviceID);
return;
}
device.close();
} catch (Exception e) {
Log.e(TAG, "Got exception: " + Log.getStackTraceString(e));
}
}
//////////////////////////////////////////////////////////////////////////////////////////////////////
/////////////// Native methods
//////////////////////////////////////////////////////////////////////////////////////////////////////
private native void HIDDeviceRegisterCallback();
private native void HIDDeviceReleaseCallback();
native void HIDDeviceConnected(int deviceID, String identifier, int vendorId, int productId, String serial_number, int release_number, String manufacturer_string, String product_string, int interface_number, int interface_class, int interface_subclass, int interface_protocol);
native void HIDDeviceOpenPending(int deviceID);
native void HIDDeviceOpenResult(int deviceID, boolean opened);
native void HIDDeviceDisconnected(int deviceID);
native void HIDDeviceInputReport(int deviceID, byte[] report);
native void HIDDeviceFeatureReport(int deviceID, byte[] report);
}

View File

@ -0,0 +1,309 @@
package org.libsdl.app;
import android.hardware.usb.*;
import android.os.Build;
import android.util.Log;
import java.util.Arrays;
class HIDDeviceUSB implements HIDDevice {
private static final String TAG = "hidapi";
protected HIDDeviceManager mManager;
protected UsbDevice mDevice;
protected int mInterfaceIndex;
protected int mInterface;
protected int mDeviceId;
protected UsbDeviceConnection mConnection;
protected UsbEndpoint mInputEndpoint;
protected UsbEndpoint mOutputEndpoint;
protected InputThread mInputThread;
protected boolean mRunning;
protected boolean mFrozen;
public HIDDeviceUSB(HIDDeviceManager manager, UsbDevice usbDevice, int interface_index) {
mManager = manager;
mDevice = usbDevice;
mInterfaceIndex = interface_index;
mInterface = mDevice.getInterface(mInterfaceIndex).getId();
mDeviceId = manager.getDeviceIDForIdentifier(getIdentifier());
mRunning = false;
}
public String getIdentifier() {
return String.format("%s/%x/%x/%d", mDevice.getDeviceName(), mDevice.getVendorId(), mDevice.getProductId(), mInterfaceIndex);
}
@Override
public int getId() {
return mDeviceId;
}
@Override
public int getVendorId() {
return mDevice.getVendorId();
}
@Override
public int getProductId() {
return mDevice.getProductId();
}
@Override
public String getSerialNumber() {
String result = null;
if (Build.VERSION.SDK_INT >= 21) {
try {
result = mDevice.getSerialNumber();
}
catch (SecurityException exception) {
//Log.w(TAG, "App permissions mean we cannot get serial number for device " + getDeviceName() + " message: " + exception.getMessage());
}
}
if (result == null) {
result = "";
}
return result;
}
@Override
public int getVersion() {
return 0;
}
@Override
public String getManufacturerName() {
String result = null;
if (Build.VERSION.SDK_INT >= 21) {
result = mDevice.getManufacturerName();
}
if (result == null) {
result = String.format("%x", getVendorId());
}
return result;
}
@Override
public String getProductName() {
String result = null;
if (Build.VERSION.SDK_INT >= 21) {
result = mDevice.getProductName();
}
if (result == null) {
result = String.format("%x", getProductId());
}
return result;
}
@Override
public UsbDevice getDevice() {
return mDevice;
}
public String getDeviceName() {
return getManufacturerName() + " " + getProductName() + "(0x" + String.format("%x", getVendorId()) + "/0x" + String.format("%x", getProductId()) + ")";
}
@Override
public boolean open() {
mConnection = mManager.getUSBManager().openDevice(mDevice);
if (mConnection == null) {
Log.w(TAG, "Unable to open USB device " + getDeviceName());
return false;
}
// Force claim our interface
UsbInterface iface = mDevice.getInterface(mInterfaceIndex);
if (!mConnection.claimInterface(iface, true)) {
Log.w(TAG, "Failed to claim interfaces on USB device " + getDeviceName());
close();
return false;
}
// Find the endpoints
for (int j = 0; j < iface.getEndpointCount(); j++) {
UsbEndpoint endpt = iface.getEndpoint(j);
switch (endpt.getDirection()) {
case UsbConstants.USB_DIR_IN:
if (mInputEndpoint == null) {
mInputEndpoint = endpt;
}
break;
case UsbConstants.USB_DIR_OUT:
if (mOutputEndpoint == null) {
mOutputEndpoint = endpt;
}
break;
}
}
// Make sure the required endpoints were present
if (mInputEndpoint == null || mOutputEndpoint == null) {
Log.w(TAG, "Missing required endpoint on USB device " + getDeviceName());
close();
return false;
}
// Start listening for input
mRunning = true;
mInputThread = new InputThread();
mInputThread.start();
return true;
}
@Override
public int sendFeatureReport(byte[] report) {
int res = -1;
int offset = 0;
int length = report.length;
boolean skipped_report_id = false;
byte report_number = report[0];
if (report_number == 0x0) {
++offset;
--length;
skipped_report_id = true;
}
res = mConnection.controlTransfer(
UsbConstants.USB_TYPE_CLASS | 0x01 /*RECIPIENT_INTERFACE*/ | UsbConstants.USB_DIR_OUT,
0x09/*HID set_report*/,
(3/*HID feature*/ << 8) | report_number,
mInterface,
report, offset, length,
1000/*timeout millis*/);
if (res < 0) {
Log.w(TAG, "sendFeatureReport() returned " + res + " on device " + getDeviceName());
return -1;
}
if (skipped_report_id) {
++length;
}
return length;
}
@Override
public int sendOutputReport(byte[] report) {
int r = mConnection.bulkTransfer(mOutputEndpoint, report, report.length, 1000);
if (r != report.length) {
Log.w(TAG, "sendOutputReport() returned " + r + " on device " + getDeviceName());
}
return r;
}
@Override
public boolean getFeatureReport(byte[] report) {
int res = -1;
int offset = 0;
int length = report.length;
boolean skipped_report_id = false;
byte report_number = report[0];
if (report_number == 0x0) {
/* Offset the return buffer by 1, so that the report ID
will remain in byte 0. */
++offset;
--length;
skipped_report_id = true;
}
res = mConnection.controlTransfer(
UsbConstants.USB_TYPE_CLASS | 0x01 /*RECIPIENT_INTERFACE*/ | UsbConstants.USB_DIR_IN,
0x01/*HID get_report*/,
(3/*HID feature*/ << 8) | report_number,
mInterface,
report, offset, length,
1000/*timeout millis*/);
if (res < 0) {
Log.w(TAG, "getFeatureReport() returned " + res + " on device " + getDeviceName());
return false;
}
if (skipped_report_id) {
++res;
++length;
}
byte[] data;
if (res == length) {
data = report;
} else {
data = Arrays.copyOfRange(report, 0, res);
}
mManager.HIDDeviceFeatureReport(mDeviceId, data);
return true;
}
@Override
public void close() {
mRunning = false;
if (mInputThread != null) {
while (mInputThread.isAlive()) {
mInputThread.interrupt();
try {
mInputThread.join();
} catch (InterruptedException e) {
// Keep trying until we're done
}
}
mInputThread = null;
}
if (mConnection != null) {
UsbInterface iface = mDevice.getInterface(mInterfaceIndex);
mConnection.releaseInterface(iface);
mConnection.close();
mConnection = null;
}
}
@Override
public void shutdown() {
close();
mManager = null;
}
@Override
public void setFrozen(boolean frozen) {
mFrozen = frozen;
}
protected class InputThread extends Thread {
@Override
public void run() {
int packetSize = mInputEndpoint.getMaxPacketSize();
byte[] packet = new byte[packetSize];
while (mRunning) {
int r;
try
{
r = mConnection.bulkTransfer(mInputEndpoint, packet, packetSize, 1000);
}
catch (Exception e)
{
Log.v(TAG, "Exception in UsbDeviceConnection bulktransfer: " + e);
break;
}
if (r < 0) {
// Could be a timeout or an I/O error
}
if (r > 0) {
byte[] data;
if (r == packetSize) {
data = packet;
} else {
data = Arrays.copyOfRange(packet, 0, r);
}
if (!mFrozen) {
mManager.HIDDeviceInputReport(mDeviceId, data);
}
}
}
}
}
}

View File

@ -0,0 +1,87 @@
package org.libsdl.app;
import android.content.Context;
import java.lang.Class;
import java.lang.reflect.Method;
import eu.vcmi.vcmi.NativeMethods;
/**
SDL library initialization
*/
public class SDL {
// This function should be called first and sets up the native code
// so it can call into the Java classes
public static void setupJNI() {
SDLActivity.nativeSetupJNI();
SDLAudioManager.nativeSetupJNI();
SDLControllerManager.nativeSetupJNI();
}
// This function should be called each time the activity is started
public static void initialize() {
setContext(null);
SDLActivity.initialize();
SDLAudioManager.initialize();
SDLControllerManager.initialize();
}
// This function stores the current activity (SDL or not)
public static void setContext(Context context) {
mContext = context;
}
public static Context getContext() {
return mContext;
}
public static void loadLibrary(String libraryName) throws UnsatisfiedLinkError, SecurityException, NullPointerException {
if (libraryName == null) {
throw new NullPointerException("No library name provided.");
}
try {
// Let's see if we have ReLinker available in the project. This is necessary for
// some projects that have huge numbers of local libraries bundled, and thus may
// trip a bug in Android's native library loader which ReLinker works around. (If
// loadLibrary works properly, ReLinker will simply use the normal Android method
// internally.)
//
// To use ReLinker, just add it as a dependency. For more information, see
// https://github.com/KeepSafe/ReLinker for ReLinker's repository.
//
Class<?> relinkClass = mContext.getClassLoader().loadClass("com.getkeepsafe.relinker.ReLinker");
Class<?> relinkListenerClass = mContext.getClassLoader().loadClass("com.getkeepsafe.relinker.ReLinker$LoadListener");
Class<?> contextClass = mContext.getClassLoader().loadClass("android.content.Context");
Class<?> stringClass = mContext.getClassLoader().loadClass("java.lang.String");
// Get a 'force' instance of the ReLinker, so we can ensure libraries are reinstalled if
// they've changed during updates.
Method forceMethod = relinkClass.getDeclaredMethod("force");
Object relinkInstance = forceMethod.invoke(null);
Class<?> relinkInstanceClass = relinkInstance.getClass();
// Actually load the library!
Method loadMethod = relinkInstanceClass.getDeclaredMethod("loadLibrary", contextClass, stringClass, stringClass, relinkListenerClass);
loadMethod.invoke(relinkInstance, mContext, libraryName, null, null);
}
catch (final Throwable e) {
// Fall back
try {
System.loadLibrary(libraryName);
}
catch (final UnsatisfiedLinkError ule) {
throw ule;
}
catch (final SecurityException se) {
throw se;
}
}
}
protected static Context mContext;
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,394 @@
package org.libsdl.app;
import android.media.AudioFormat;
import android.media.AudioManager;
import android.media.AudioRecord;
import android.media.AudioTrack;
import android.media.MediaRecorder;
import android.os.Build;
import android.util.Log;
public class SDLAudioManager
{
protected static final String TAG = "SDLAudio";
protected static AudioTrack mAudioTrack;
protected static AudioRecord mAudioRecord;
public static void initialize() {
mAudioTrack = null;
mAudioRecord = null;
}
// Audio
protected static String getAudioFormatString(int audioFormat) {
switch (audioFormat) {
case AudioFormat.ENCODING_PCM_8BIT:
return "8-bit";
case AudioFormat.ENCODING_PCM_16BIT:
return "16-bit";
case AudioFormat.ENCODING_PCM_FLOAT:
return "float";
default:
return Integer.toString(audioFormat);
}
}
protected static int[] open(boolean isCapture, int sampleRate, int audioFormat, int desiredChannels, int desiredFrames) {
int channelConfig;
int sampleSize;
int frameSize;
Log.v(TAG, "Opening " + (isCapture ? "capture" : "playback") + ", requested " + desiredFrames + " frames of " + desiredChannels + " channel " + getAudioFormatString(audioFormat) + " audio at " + sampleRate + " Hz");
/* On older devices let's use known good settings */
if (Build.VERSION.SDK_INT < 21) {
if (desiredChannels > 2) {
desiredChannels = 2;
}
}
/* AudioTrack has sample rate limitation of 48000 (fixed in 5.0.2) */
if (Build.VERSION.SDK_INT < 22) {
if (sampleRate < 8000) {
sampleRate = 8000;
} else if (sampleRate > 48000) {
sampleRate = 48000;
}
}
if (audioFormat == AudioFormat.ENCODING_PCM_FLOAT) {
int minSDKVersion = (isCapture ? 23 : 21);
if (Build.VERSION.SDK_INT < minSDKVersion) {
audioFormat = AudioFormat.ENCODING_PCM_16BIT;
}
}
switch (audioFormat)
{
case AudioFormat.ENCODING_PCM_8BIT:
sampleSize = 1;
break;
case AudioFormat.ENCODING_PCM_16BIT:
sampleSize = 2;
break;
case AudioFormat.ENCODING_PCM_FLOAT:
sampleSize = 4;
break;
default:
Log.v(TAG, "Requested format " + audioFormat + ", getting ENCODING_PCM_16BIT");
audioFormat = AudioFormat.ENCODING_PCM_16BIT;
sampleSize = 2;
break;
}
if (isCapture) {
switch (desiredChannels) {
case 1:
channelConfig = AudioFormat.CHANNEL_IN_MONO;
break;
case 2:
channelConfig = AudioFormat.CHANNEL_IN_STEREO;
break;
default:
Log.v(TAG, "Requested " + desiredChannels + " channels, getting stereo");
desiredChannels = 2;
channelConfig = AudioFormat.CHANNEL_IN_STEREO;
break;
}
} else {
switch (desiredChannels) {
case 1:
channelConfig = AudioFormat.CHANNEL_OUT_MONO;
break;
case 2:
channelConfig = AudioFormat.CHANNEL_OUT_STEREO;
break;
case 3:
channelConfig = AudioFormat.CHANNEL_OUT_STEREO | AudioFormat.CHANNEL_OUT_FRONT_CENTER;
break;
case 4:
channelConfig = AudioFormat.CHANNEL_OUT_QUAD;
break;
case 5:
channelConfig = AudioFormat.CHANNEL_OUT_QUAD | AudioFormat.CHANNEL_OUT_FRONT_CENTER;
break;
case 6:
channelConfig = AudioFormat.CHANNEL_OUT_5POINT1;
break;
case 7:
channelConfig = AudioFormat.CHANNEL_OUT_5POINT1 | AudioFormat.CHANNEL_OUT_BACK_CENTER;
break;
case 8:
if (Build.VERSION.SDK_INT >= 23) {
channelConfig = AudioFormat.CHANNEL_OUT_7POINT1_SURROUND;
} else {
Log.v(TAG, "Requested " + desiredChannels + " channels, getting 5.1 surround");
desiredChannels = 6;
channelConfig = AudioFormat.CHANNEL_OUT_5POINT1;
}
break;
default:
Log.v(TAG, "Requested " + desiredChannels + " channels, getting stereo");
desiredChannels = 2;
channelConfig = AudioFormat.CHANNEL_OUT_STEREO;
break;
}
/*
Log.v(TAG, "Speaker configuration (and order of channels):");
if ((channelConfig & 0x00000004) != 0) {
Log.v(TAG, " CHANNEL_OUT_FRONT_LEFT");
}
if ((channelConfig & 0x00000008) != 0) {
Log.v(TAG, " CHANNEL_OUT_FRONT_RIGHT");
}
if ((channelConfig & 0x00000010) != 0) {
Log.v(TAG, " CHANNEL_OUT_FRONT_CENTER");
}
if ((channelConfig & 0x00000020) != 0) {
Log.v(TAG, " CHANNEL_OUT_LOW_FREQUENCY");
}
if ((channelConfig & 0x00000040) != 0) {
Log.v(TAG, " CHANNEL_OUT_BACK_LEFT");
}
if ((channelConfig & 0x00000080) != 0) {
Log.v(TAG, " CHANNEL_OUT_BACK_RIGHT");
}
if ((channelConfig & 0x00000100) != 0) {
Log.v(TAG, " CHANNEL_OUT_FRONT_LEFT_OF_CENTER");
}
if ((channelConfig & 0x00000200) != 0) {
Log.v(TAG, " CHANNEL_OUT_FRONT_RIGHT_OF_CENTER");
}
if ((channelConfig & 0x00000400) != 0) {
Log.v(TAG, " CHANNEL_OUT_BACK_CENTER");
}
if ((channelConfig & 0x00000800) != 0) {
Log.v(TAG, " CHANNEL_OUT_SIDE_LEFT");
}
if ((channelConfig & 0x00001000) != 0) {
Log.v(TAG, " CHANNEL_OUT_SIDE_RIGHT");
}
*/
}
frameSize = (sampleSize * desiredChannels);
// Let the user pick a larger buffer if they really want -- but ye
// gods they probably shouldn't, the minimums are horrifyingly high
// latency already
int minBufferSize;
if (isCapture) {
minBufferSize = AudioRecord.getMinBufferSize(sampleRate, channelConfig, audioFormat);
} else {
minBufferSize = AudioTrack.getMinBufferSize(sampleRate, channelConfig, audioFormat);
}
desiredFrames = Math.max(desiredFrames, (minBufferSize + frameSize - 1) / frameSize);
int[] results = new int[4];
if (isCapture) {
if (mAudioRecord == null) {
mAudioRecord = new AudioRecord(MediaRecorder.AudioSource.DEFAULT, sampleRate,
channelConfig, audioFormat, desiredFrames * frameSize);
// see notes about AudioTrack state in audioOpen(), above. Probably also applies here.
if (mAudioRecord.getState() != AudioRecord.STATE_INITIALIZED) {
Log.e(TAG, "Failed during initialization of AudioRecord");
mAudioRecord.release();
mAudioRecord = null;
return null;
}
mAudioRecord.startRecording();
}
results[0] = mAudioRecord.getSampleRate();
results[1] = mAudioRecord.getAudioFormat();
results[2] = mAudioRecord.getChannelCount();
} else {
if (mAudioTrack == null) {
mAudioTrack = new AudioTrack(AudioManager.STREAM_MUSIC, sampleRate, channelConfig, audioFormat, desiredFrames * frameSize, AudioTrack.MODE_STREAM);
// Instantiating AudioTrack can "succeed" without an exception and the track may still be invalid
// Ref: https://android.googlesource.com/platform/frameworks/base/+/refs/heads/master/media/java/android/media/AudioTrack.java
// Ref: http://developer.android.com/reference/android/media/AudioTrack.html#getState()
if (mAudioTrack.getState() != AudioTrack.STATE_INITIALIZED) {
/* Try again, with safer values */
Log.e(TAG, "Failed during initialization of Audio Track");
mAudioTrack.release();
mAudioTrack = null;
return null;
}
mAudioTrack.play();
}
results[0] = mAudioTrack.getSampleRate();
results[1] = mAudioTrack.getAudioFormat();
results[2] = mAudioTrack.getChannelCount();
}
results[3] = desiredFrames;
Log.v(TAG, "Opening " + (isCapture ? "capture" : "playback") + ", got " + results[3] + " frames of " + results[2] + " channel " + getAudioFormatString(results[1]) + " audio at " + results[0] + " Hz");
return results;
}
/**
* This method is called by SDL using JNI.
*/
public static int[] audioOpen(int sampleRate, int audioFormat, int desiredChannels, int desiredFrames) {
return open(false, sampleRate, audioFormat, desiredChannels, desiredFrames);
}
/**
* This method is called by SDL using JNI.
*/
public static void audioWriteFloatBuffer(float[] buffer) {
if (mAudioTrack == null) {
Log.e(TAG, "Attempted to make audio call with uninitialized audio!");
return;
}
for (int i = 0; i < buffer.length;) {
int result = mAudioTrack.write(buffer, i, buffer.length - i, AudioTrack.WRITE_BLOCKING);
if (result > 0) {
i += result;
} else if (result == 0) {
try {
Thread.sleep(1);
} catch(InterruptedException e) {
// Nom nom
}
} else {
Log.w(TAG, "SDL audio: error return from write(float)");
return;
}
}
}
/**
* This method is called by SDL using JNI.
*/
public static void audioWriteShortBuffer(short[] buffer) {
if (mAudioTrack == null) {
Log.e(TAG, "Attempted to make audio call with uninitialized audio!");
return;
}
for (int i = 0; i < buffer.length;) {
int result = mAudioTrack.write(buffer, i, buffer.length - i);
if (result > 0) {
i += result;
} else if (result == 0) {
try {
Thread.sleep(1);
} catch(InterruptedException e) {
// Nom nom
}
} else {
Log.w(TAG, "SDL audio: error return from write(short)");
return;
}
}
}
/**
* This method is called by SDL using JNI.
*/
public static void audioWriteByteBuffer(byte[] buffer) {
if (mAudioTrack == null) {
Log.e(TAG, "Attempted to make audio call with uninitialized audio!");
return;
}
for (int i = 0; i < buffer.length; ) {
int result = mAudioTrack.write(buffer, i, buffer.length - i);
if (result > 0) {
i += result;
} else if (result == 0) {
try {
Thread.sleep(1);
} catch(InterruptedException e) {
// Nom nom
}
} else {
Log.w(TAG, "SDL audio: error return from write(byte)");
return;
}
}
}
/**
* This method is called by SDL using JNI.
*/
public static int[] captureOpen(int sampleRate, int audioFormat, int desiredChannels, int desiredFrames) {
return open(true, sampleRate, audioFormat, desiredChannels, desiredFrames);
}
/** This method is called by SDL using JNI. */
public static int captureReadFloatBuffer(float[] buffer, boolean blocking) {
return mAudioRecord.read(buffer, 0, buffer.length, blocking ? AudioRecord.READ_BLOCKING : AudioRecord.READ_NON_BLOCKING);
}
/** This method is called by SDL using JNI. */
public static int captureReadShortBuffer(short[] buffer, boolean blocking) {
if (Build.VERSION.SDK_INT < 23) {
return mAudioRecord.read(buffer, 0, buffer.length);
} else {
return mAudioRecord.read(buffer, 0, buffer.length, blocking ? AudioRecord.READ_BLOCKING : AudioRecord.READ_NON_BLOCKING);
}
}
/** This method is called by SDL using JNI. */
public static int captureReadByteBuffer(byte[] buffer, boolean blocking) {
if (Build.VERSION.SDK_INT < 23) {
return mAudioRecord.read(buffer, 0, buffer.length);
} else {
return mAudioRecord.read(buffer, 0, buffer.length, blocking ? AudioRecord.READ_BLOCKING : AudioRecord.READ_NON_BLOCKING);
}
}
/** This method is called by SDL using JNI. */
public static void audioClose() {
if (mAudioTrack != null) {
mAudioTrack.stop();
mAudioTrack.release();
mAudioTrack = null;
}
}
/** This method is called by SDL using JNI. */
public static void captureClose() {
if (mAudioRecord != null) {
mAudioRecord.stop();
mAudioRecord.release();
mAudioRecord = null;
}
}
/** This method is called by SDL using JNI. */
public static void audioSetThreadPriority(boolean iscapture, int device_id) {
try {
/* Set thread name */
if (iscapture) {
Thread.currentThread().setName("SDLAudioC" + device_id);
} else {
Thread.currentThread().setName("SDLAudioP" + device_id);
}
/* Set thread priority */
android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_AUDIO);
} catch (Exception e) {
Log.v(TAG, "modify thread properties failed " + e.toString());
}
}
public static native int nativeSetupJNI();
}

View File

@ -0,0 +1,788 @@
package org.libsdl.app;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import android.content.Context;
import android.os.Build;
import android.os.VibrationEffect;
import android.os.Vibrator;
import android.util.Log;
import android.view.InputDevice;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.View;
public class SDLControllerManager
{
public static native int nativeSetupJNI();
public static native int nativeAddJoystick(int device_id, String name, String desc,
int vendor_id, int product_id,
boolean is_accelerometer, int button_mask,
int naxes, int nhats, int nballs);
public static native int nativeRemoveJoystick(int device_id);
public static native int nativeAddHaptic(int device_id, String name);
public static native int nativeRemoveHaptic(int device_id);
public static native int onNativePadDown(int device_id, int keycode);
public static native int onNativePadUp(int device_id, int keycode);
public static native void onNativeJoy(int device_id, int axis,
float value);
public static native void onNativeHat(int device_id, int hat_id,
int x, int y);
protected static SDLJoystickHandler mJoystickHandler;
protected static SDLHapticHandler mHapticHandler;
private static final String TAG = "SDLControllerManager";
public static void initialize() {
if (mJoystickHandler == null) {
if (Build.VERSION.SDK_INT >= 19) {
mJoystickHandler = new SDLJoystickHandler_API19();
} else {
mJoystickHandler = new SDLJoystickHandler_API16();
}
}
if (mHapticHandler == null) {
if (Build.VERSION.SDK_INT >= 26) {
mHapticHandler = new SDLHapticHandler_API26();
} else {
mHapticHandler = new SDLHapticHandler();
}
}
}
// Joystick glue code, just a series of stubs that redirect to the SDLJoystickHandler instance
public static boolean handleJoystickMotionEvent(MotionEvent event) {
return mJoystickHandler.handleMotionEvent(event);
}
/**
* This method is called by SDL using JNI.
*/
public static void pollInputDevices() {
mJoystickHandler.pollInputDevices();
}
/**
* This method is called by SDL using JNI.
*/
public static void pollHapticDevices() {
mHapticHandler.pollHapticDevices();
}
/**
* This method is called by SDL using JNI.
*/
public static void hapticRun(int device_id, float intensity, int length) {
mHapticHandler.run(device_id, intensity, length);
}
/**
* This method is called by SDL using JNI.
*/
public static void hapticStop(int device_id)
{
mHapticHandler.stop(device_id);
}
// Check if a given device is considered a possible SDL joystick
public static boolean isDeviceSDLJoystick(int deviceId) {
InputDevice device = InputDevice.getDevice(deviceId);
// We cannot use InputDevice.isVirtual before API 16, so let's accept
// only nonnegative device ids (VIRTUAL_KEYBOARD equals -1)
if ((device == null) || (deviceId < 0)) {
return false;
}
int sources = device.getSources();
/* This is called for every button press, so let's not spam the logs */
/*
if ((sources & InputDevice.SOURCE_CLASS_JOYSTICK) != 0) {
Log.v(TAG, "Input device " + device.getName() + " has class joystick.");
}
if ((sources & InputDevice.SOURCE_DPAD) == InputDevice.SOURCE_DPAD) {
Log.v(TAG, "Input device " + device.getName() + " is a dpad.");
}
if ((sources & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD) {
Log.v(TAG, "Input device " + device.getName() + " is a gamepad.");
}
*/
return ((sources & InputDevice.SOURCE_CLASS_JOYSTICK) != 0 ||
((sources & InputDevice.SOURCE_DPAD) == InputDevice.SOURCE_DPAD) ||
((sources & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD)
);
}
}
class SDLJoystickHandler {
/**
* Handles given MotionEvent.
* @param event the event to be handled.
* @return if given event was processed.
*/
public boolean handleMotionEvent(MotionEvent event) {
return false;
}
/**
* Handles adding and removing of input devices.
*/
public void pollInputDevices() {
}
}
/* Actual joystick functionality available for API >= 12 devices */
class SDLJoystickHandler_API16 extends SDLJoystickHandler {
static class SDLJoystick {
public int device_id;
public String name;
public String desc;
public ArrayList<InputDevice.MotionRange> axes;
public ArrayList<InputDevice.MotionRange> hats;
}
static class RangeComparator implements Comparator<InputDevice.MotionRange> {
@Override
public int compare(InputDevice.MotionRange arg0, InputDevice.MotionRange arg1) {
// Some controllers, like the Moga Pro 2, return AXIS_GAS (22) for right trigger and AXIS_BRAKE (23) for left trigger - swap them so they're sorted in the right order for SDL
int arg0Axis = arg0.getAxis();
int arg1Axis = arg1.getAxis();
if (arg0Axis == MotionEvent.AXIS_GAS) {
arg0Axis = MotionEvent.AXIS_BRAKE;
} else if (arg0Axis == MotionEvent.AXIS_BRAKE) {
arg0Axis = MotionEvent.AXIS_GAS;
}
if (arg1Axis == MotionEvent.AXIS_GAS) {
arg1Axis = MotionEvent.AXIS_BRAKE;
} else if (arg1Axis == MotionEvent.AXIS_BRAKE) {
arg1Axis = MotionEvent.AXIS_GAS;
}
return arg0Axis - arg1Axis;
}
}
private final ArrayList<SDLJoystick> mJoysticks;
public SDLJoystickHandler_API16() {
mJoysticks = new ArrayList<SDLJoystick>();
}
@Override
public void pollInputDevices() {
int[] deviceIds = InputDevice.getDeviceIds();
for (int device_id : deviceIds) {
if (SDLControllerManager.isDeviceSDLJoystick(device_id)) {
SDLJoystick joystick = getJoystick(device_id);
if (joystick == null) {
InputDevice joystickDevice = InputDevice.getDevice(device_id);
joystick = new SDLJoystick();
joystick.device_id = device_id;
joystick.name = joystickDevice.getName();
joystick.desc = getJoystickDescriptor(joystickDevice);
joystick.axes = new ArrayList<InputDevice.MotionRange>();
joystick.hats = new ArrayList<InputDevice.MotionRange>();
List<InputDevice.MotionRange> ranges = joystickDevice.getMotionRanges();
Collections.sort(ranges, new RangeComparator());
for (InputDevice.MotionRange range : ranges) {
if ((range.getSource() & InputDevice.SOURCE_CLASS_JOYSTICK) != 0) {
if (range.getAxis() == MotionEvent.AXIS_HAT_X || range.getAxis() == MotionEvent.AXIS_HAT_Y) {
joystick.hats.add(range);
} else {
joystick.axes.add(range);
}
}
}
mJoysticks.add(joystick);
SDLControllerManager.nativeAddJoystick(joystick.device_id, joystick.name, joystick.desc,
getVendorId(joystickDevice), getProductId(joystickDevice), false,
getButtonMask(joystickDevice), joystick.axes.size(), joystick.hats.size()/2, 0);
}
}
}
/* Check removed devices */
ArrayList<Integer> removedDevices = null;
for (SDLJoystick joystick : mJoysticks) {
int device_id = joystick.device_id;
int i;
for (i = 0; i < deviceIds.length; i++) {
if (device_id == deviceIds[i]) break;
}
if (i == deviceIds.length) {
if (removedDevices == null) {
removedDevices = new ArrayList<Integer>();
}
removedDevices.add(device_id);
}
}
if (removedDevices != null) {
for (int device_id : removedDevices) {
SDLControllerManager.nativeRemoveJoystick(device_id);
for (int i = 0; i < mJoysticks.size(); i++) {
if (mJoysticks.get(i).device_id == device_id) {
mJoysticks.remove(i);
break;
}
}
}
}
}
protected SDLJoystick getJoystick(int device_id) {
for (SDLJoystick joystick : mJoysticks) {
if (joystick.device_id == device_id) {
return joystick;
}
}
return null;
}
@Override
public boolean handleMotionEvent(MotionEvent event) {
int actionPointerIndex = event.getActionIndex();
int action = event.getActionMasked();
if (action == MotionEvent.ACTION_MOVE) {
SDLJoystick joystick = getJoystick(event.getDeviceId());
if (joystick != null) {
for (int i = 0; i < joystick.axes.size(); i++) {
InputDevice.MotionRange range = joystick.axes.get(i);
/* Normalize the value to -1...1 */
float value = (event.getAxisValue(range.getAxis(), actionPointerIndex) - range.getMin()) / range.getRange() * 2.0f - 1.0f;
SDLControllerManager.onNativeJoy(joystick.device_id, i, value);
}
for (int i = 0; i < joystick.hats.size() / 2; i++) {
int hatX = Math.round(event.getAxisValue(joystick.hats.get(2 * i).getAxis(), actionPointerIndex));
int hatY = Math.round(event.getAxisValue(joystick.hats.get(2 * i + 1).getAxis(), actionPointerIndex));
SDLControllerManager.onNativeHat(joystick.device_id, i, hatX, hatY);
}
}
}
return true;
}
public String getJoystickDescriptor(InputDevice joystickDevice) {
String desc = joystickDevice.getDescriptor();
if (desc != null && !desc.isEmpty()) {
return desc;
}
return joystickDevice.getName();
}
public int getProductId(InputDevice joystickDevice) {
return 0;
}
public int getVendorId(InputDevice joystickDevice) {
return 0;
}
public int getButtonMask(InputDevice joystickDevice) {
return -1;
}
}
class SDLJoystickHandler_API19 extends SDLJoystickHandler_API16 {
@Override
public int getProductId(InputDevice joystickDevice) {
return joystickDevice.getProductId();
}
@Override
public int getVendorId(InputDevice joystickDevice) {
return joystickDevice.getVendorId();
}
@Override
public int getButtonMask(InputDevice joystickDevice) {
int button_mask = 0;
int[] keys = new int[] {
KeyEvent.KEYCODE_BUTTON_A,
KeyEvent.KEYCODE_BUTTON_B,
KeyEvent.KEYCODE_BUTTON_X,
KeyEvent.KEYCODE_BUTTON_Y,
KeyEvent.KEYCODE_BACK,
KeyEvent.KEYCODE_MENU,
KeyEvent.KEYCODE_BUTTON_MODE,
KeyEvent.KEYCODE_BUTTON_START,
KeyEvent.KEYCODE_BUTTON_THUMBL,
KeyEvent.KEYCODE_BUTTON_THUMBR,
KeyEvent.KEYCODE_BUTTON_L1,
KeyEvent.KEYCODE_BUTTON_R1,
KeyEvent.KEYCODE_DPAD_UP,
KeyEvent.KEYCODE_DPAD_DOWN,
KeyEvent.KEYCODE_DPAD_LEFT,
KeyEvent.KEYCODE_DPAD_RIGHT,
KeyEvent.KEYCODE_BUTTON_SELECT,
KeyEvent.KEYCODE_DPAD_CENTER,
// These don't map into any SDL controller buttons directly
KeyEvent.KEYCODE_BUTTON_L2,
KeyEvent.KEYCODE_BUTTON_R2,
KeyEvent.KEYCODE_BUTTON_C,
KeyEvent.KEYCODE_BUTTON_Z,
KeyEvent.KEYCODE_BUTTON_1,
KeyEvent.KEYCODE_BUTTON_2,
KeyEvent.KEYCODE_BUTTON_3,
KeyEvent.KEYCODE_BUTTON_4,
KeyEvent.KEYCODE_BUTTON_5,
KeyEvent.KEYCODE_BUTTON_6,
KeyEvent.KEYCODE_BUTTON_7,
KeyEvent.KEYCODE_BUTTON_8,
KeyEvent.KEYCODE_BUTTON_9,
KeyEvent.KEYCODE_BUTTON_10,
KeyEvent.KEYCODE_BUTTON_11,
KeyEvent.KEYCODE_BUTTON_12,
KeyEvent.KEYCODE_BUTTON_13,
KeyEvent.KEYCODE_BUTTON_14,
KeyEvent.KEYCODE_BUTTON_15,
KeyEvent.KEYCODE_BUTTON_16,
};
int[] masks = new int[] {
(1 << 0), // A -> A
(1 << 1), // B -> B
(1 << 2), // X -> X
(1 << 3), // Y -> Y
(1 << 4), // BACK -> BACK
(1 << 6), // MENU -> START
(1 << 5), // MODE -> GUIDE
(1 << 6), // START -> START
(1 << 7), // THUMBL -> LEFTSTICK
(1 << 8), // THUMBR -> RIGHTSTICK
(1 << 9), // L1 -> LEFTSHOULDER
(1 << 10), // R1 -> RIGHTSHOULDER
(1 << 11), // DPAD_UP -> DPAD_UP
(1 << 12), // DPAD_DOWN -> DPAD_DOWN
(1 << 13), // DPAD_LEFT -> DPAD_LEFT
(1 << 14), // DPAD_RIGHT -> DPAD_RIGHT
(1 << 4), // SELECT -> BACK
(1 << 0), // DPAD_CENTER -> A
(1 << 15), // L2 -> ??
(1 << 16), // R2 -> ??
(1 << 17), // C -> ??
(1 << 18), // Z -> ??
(1 << 20), // 1 -> ??
(1 << 21), // 2 -> ??
(1 << 22), // 3 -> ??
(1 << 23), // 4 -> ??
(1 << 24), // 5 -> ??
(1 << 25), // 6 -> ??
(1 << 26), // 7 -> ??
(1 << 27), // 8 -> ??
(1 << 28), // 9 -> ??
(1 << 29), // 10 -> ??
(1 << 30), // 11 -> ??
(1 << 31), // 12 -> ??
// We're out of room...
0xFFFFFFFF, // 13 -> ??
0xFFFFFFFF, // 14 -> ??
0xFFFFFFFF, // 15 -> ??
0xFFFFFFFF, // 16 -> ??
};
boolean[] has_keys = joystickDevice.hasKeys(keys);
for (int i = 0; i < keys.length; ++i) {
if (has_keys[i]) {
button_mask |= masks[i];
}
}
return button_mask;
}
}
class SDLHapticHandler_API26 extends SDLHapticHandler {
@Override
public void run(int device_id, float intensity, int length) {
SDLHaptic haptic = getHaptic(device_id);
if (haptic != null) {
Log.d("SDL", "Rtest: Vibe with intensity " + intensity + " for " + length);
if (intensity == 0.0f) {
stop(device_id);
return;
}
int vibeValue = Math.round(intensity * 255);
if (vibeValue > 255) {
vibeValue = 255;
}
if (vibeValue < 1) {
stop(device_id);
return;
}
try {
haptic.vib.vibrate(VibrationEffect.createOneShot(length, vibeValue));
}
catch (Exception e) {
// Fall back to the generic method, which uses DEFAULT_AMPLITUDE, but works even if
// something went horribly wrong with the Android 8.0 APIs.
haptic.vib.vibrate(length);
}
}
}
}
class SDLHapticHandler {
static class SDLHaptic {
public int device_id;
public String name;
public Vibrator vib;
}
private final ArrayList<SDLHaptic> mHaptics;
public SDLHapticHandler() {
mHaptics = new ArrayList<SDLHaptic>();
}
public void run(int device_id, float intensity, int length) {
SDLHaptic haptic = getHaptic(device_id);
if (haptic != null) {
haptic.vib.vibrate(length);
}
}
public void stop(int device_id) {
SDLHaptic haptic = getHaptic(device_id);
if (haptic != null) {
haptic.vib.cancel();
}
}
public void pollHapticDevices() {
final int deviceId_VIBRATOR_SERVICE = 999999;
boolean hasVibratorService = false;
int[] deviceIds = InputDevice.getDeviceIds();
// It helps processing the device ids in reverse order
// For example, in the case of the XBox 360 wireless dongle,
// so the first controller seen by SDL matches what the receiver
// considers to be the first controller
for (int i = deviceIds.length - 1; i > -1; i--) {
SDLHaptic haptic = getHaptic(deviceIds[i]);
if (haptic == null) {
InputDevice device = InputDevice.getDevice(deviceIds[i]);
Vibrator vib = device.getVibrator();
if (vib.hasVibrator()) {
haptic = new SDLHaptic();
haptic.device_id = deviceIds[i];
haptic.name = device.getName();
haptic.vib = vib;
mHaptics.add(haptic);
SDLControllerManager.nativeAddHaptic(haptic.device_id, haptic.name);
}
}
}
/* Check VIBRATOR_SERVICE */
Vibrator vib = (Vibrator) SDL.getContext().getSystemService(Context.VIBRATOR_SERVICE);
if (vib != null) {
hasVibratorService = vib.hasVibrator();
if (hasVibratorService) {
SDLHaptic haptic = getHaptic(deviceId_VIBRATOR_SERVICE);
if (haptic == null) {
haptic = new SDLHaptic();
haptic.device_id = deviceId_VIBRATOR_SERVICE;
haptic.name = "VIBRATOR_SERVICE";
haptic.vib = vib;
mHaptics.add(haptic);
SDLControllerManager.nativeAddHaptic(haptic.device_id, haptic.name);
}
}
}
/* Check removed devices */
ArrayList<Integer> removedDevices = null;
for (SDLHaptic haptic : mHaptics) {
int device_id = haptic.device_id;
int i;
for (i = 0; i < deviceIds.length; i++) {
if (device_id == deviceIds[i]) break;
}
if (device_id != deviceId_VIBRATOR_SERVICE || !hasVibratorService) {
if (i == deviceIds.length) {
if (removedDevices == null) {
removedDevices = new ArrayList<Integer>();
}
removedDevices.add(device_id);
}
} // else: don't remove the vibrator if it is still present
}
if (removedDevices != null) {
for (int device_id : removedDevices) {
SDLControllerManager.nativeRemoveHaptic(device_id);
for (int i = 0; i < mHaptics.size(); i++) {
if (mHaptics.get(i).device_id == device_id) {
mHaptics.remove(i);
break;
}
}
}
}
}
protected SDLHaptic getHaptic(int device_id) {
for (SDLHaptic haptic : mHaptics) {
if (haptic.device_id == device_id) {
return haptic;
}
}
return null;
}
}
class SDLGenericMotionListener_API12 implements View.OnGenericMotionListener {
// Generic Motion (mouse hover, joystick...) events go here
@Override
public boolean onGenericMotion(View v, MotionEvent event) {
float x, y;
int action;
switch ( event.getSource() ) {
case InputDevice.SOURCE_JOYSTICK:
return SDLControllerManager.handleJoystickMotionEvent(event);
case InputDevice.SOURCE_MOUSE:
action = event.getActionMasked();
switch (action) {
case MotionEvent.ACTION_SCROLL:
x = event.getAxisValue(MotionEvent.AXIS_HSCROLL, 0);
y = event.getAxisValue(MotionEvent.AXIS_VSCROLL, 0);
SDLActivity.onNativeMouse(0, action, x, y, false);
return true;
case MotionEvent.ACTION_HOVER_MOVE:
x = event.getX(0);
y = event.getY(0);
SDLActivity.onNativeMouse(0, action, x, y, false);
return true;
default:
break;
}
break;
default:
break;
}
// Event was not managed
return false;
}
public boolean supportsRelativeMouse() {
return false;
}
public boolean inRelativeMode() {
return false;
}
public boolean setRelativeMouseEnabled(boolean enabled) {
return false;
}
public void reclaimRelativeMouseModeIfNeeded()
{
}
public float getEventX(MotionEvent event) {
return event.getX(0);
}
public float getEventY(MotionEvent event) {
return event.getY(0);
}
}
class SDLGenericMotionListener_API24 extends SDLGenericMotionListener_API12 {
// Generic Motion (mouse hover, joystick...) events go here
private boolean mRelativeModeEnabled;
@Override
public boolean onGenericMotion(View v, MotionEvent event) {
// Handle relative mouse mode
if (mRelativeModeEnabled) {
if (event.getSource() == InputDevice.SOURCE_MOUSE) {
int action = event.getActionMasked();
if (action == MotionEvent.ACTION_HOVER_MOVE) {
float x = event.getAxisValue(MotionEvent.AXIS_RELATIVE_X);
float y = event.getAxisValue(MotionEvent.AXIS_RELATIVE_Y);
SDLActivity.onNativeMouse(0, action, x, y, true);
return true;
}
}
}
// Event was not managed, call SDLGenericMotionListener_API12 method
return super.onGenericMotion(v, event);
}
@Override
public boolean supportsRelativeMouse() {
return true;
}
@Override
public boolean inRelativeMode() {
return mRelativeModeEnabled;
}
@Override
public boolean setRelativeMouseEnabled(boolean enabled) {
mRelativeModeEnabled = enabled;
return true;
}
@Override
public float getEventX(MotionEvent event) {
if (mRelativeModeEnabled) {
return event.getAxisValue(MotionEvent.AXIS_RELATIVE_X);
} else {
return event.getX(0);
}
}
@Override
public float getEventY(MotionEvent event) {
if (mRelativeModeEnabled) {
return event.getAxisValue(MotionEvent.AXIS_RELATIVE_Y);
} else {
return event.getY(0);
}
}
}
class SDLGenericMotionListener_API26 extends SDLGenericMotionListener_API24 {
// Generic Motion (mouse hover, joystick...) events go here
private boolean mRelativeModeEnabled;
@Override
public boolean onGenericMotion(View v, MotionEvent event) {
float x, y;
int action;
switch ( event.getSource() ) {
case InputDevice.SOURCE_JOYSTICK:
return SDLControllerManager.handleJoystickMotionEvent(event);
case InputDevice.SOURCE_MOUSE:
// DeX desktop mouse cursor is a separate non-standard input type.
case InputDevice.SOURCE_MOUSE | InputDevice.SOURCE_TOUCHSCREEN:
action = event.getActionMasked();
switch (action) {
case MotionEvent.ACTION_SCROLL:
x = event.getAxisValue(MotionEvent.AXIS_HSCROLL, 0);
y = event.getAxisValue(MotionEvent.AXIS_VSCROLL, 0);
SDLActivity.onNativeMouse(0, action, x, y, false);
return true;
case MotionEvent.ACTION_HOVER_MOVE:
x = event.getX(0);
y = event.getY(0);
SDLActivity.onNativeMouse(0, action, x, y, false);
return true;
default:
break;
}
break;
case InputDevice.SOURCE_MOUSE_RELATIVE:
action = event.getActionMasked();
switch (action) {
case MotionEvent.ACTION_SCROLL:
x = event.getAxisValue(MotionEvent.AXIS_HSCROLL, 0);
y = event.getAxisValue(MotionEvent.AXIS_VSCROLL, 0);
SDLActivity.onNativeMouse(0, action, x, y, false);
return true;
case MotionEvent.ACTION_HOVER_MOVE:
x = event.getX(0);
y = event.getY(0);
SDLActivity.onNativeMouse(0, action, x, y, true);
return true;
default:
break;
}
break;
default:
break;
}
// Event was not managed
return false;
}
@Override
public boolean supportsRelativeMouse() {
return (!SDLActivity.isDeXMode() || (Build.VERSION.SDK_INT >= 27));
}
@Override
public boolean inRelativeMode() {
return mRelativeModeEnabled;
}
@Override
public boolean setRelativeMouseEnabled(boolean enabled) {
if (!SDLActivity.isDeXMode() || (Build.VERSION.SDK_INT >= 27)) {
if (enabled) {
SDLActivity.getContentView().requestPointerCapture();
} else {
SDLActivity.getContentView().releasePointerCapture();
}
mRelativeModeEnabled = enabled;
return true;
} else {
return false;
}
}
@Override
public void reclaimRelativeMouseModeIfNeeded()
{
if (mRelativeModeEnabled && !SDLActivity.isDeXMode()) {
SDLActivity.getContentView().requestPointerCapture();
}
}
@Override
public float getEventX(MotionEvent event) {
// Relative mouse in capture mode will only have relative for X/Y
return event.getX(0);
}
@Override
public float getEventY(MotionEvent event) {
// Relative mouse in capture mode will only have relative for X/Y
return event.getY(0);
}
}

View File

@ -0,0 +1,144 @@
package org.libsdl.app;
import android.text.Editable;
import android.view.KeyEvent;
import android.view.View;
import android.view.inputmethod.BaseInputConnection;
import android.widget.EditText;
public class SDLInputConnection extends BaseInputConnection
{
protected EditText mEditText;
protected String mCommittedText = "";
public SDLInputConnection(View targetView, boolean fullEditor)
{
super(targetView, fullEditor);
mEditText = new EditText(SDL.getContext());
}
@Override
public Editable getEditable()
{
return mEditText.getEditableText();
}
@Override
public boolean sendKeyEvent(KeyEvent event)
{
/*
* This used to handle the keycodes from soft keyboard (and IME-translated input from hardkeyboard)
* However, as of Ice Cream Sandwich and later, almost all soft keyboard doesn't generate key presses
* and so we need to generate them ourselves in commitText. To avoid duplicates on the handful of keys
* that still do, we empty this out.
*/
/*
* Return DOES still generate a key event, however. So rather than using it as the 'click a button' key
* as we do with physical keyboards, let's just use it to hide the keyboard.
*/
if (event.getKeyCode() == KeyEvent.KEYCODE_ENTER)
{
if (SDLActivity.onNativeSoftReturnKey())
{
return true;
}
}
return super.sendKeyEvent(event);
}
@Override
public boolean commitText(CharSequence text, int newCursorPosition)
{
if (!super.commitText(text, newCursorPosition))
{
return false;
}
updateText();
return true;
}
@Override
public boolean setComposingText(CharSequence text, int newCursorPosition)
{
if (!super.setComposingText(text, newCursorPosition))
{
return false;
}
updateText();
return true;
}
@Override
public boolean deleteSurroundingText(int beforeLength, int afterLength)
{
if (!super.deleteSurroundingText(beforeLength, afterLength))
{
return false;
}
updateText();
return true;
}
protected void updateText()
{
final Editable content = getEditable();
if (content == null)
{
return;
}
String text = content.toString();
int compareLength = Math.min(text.length(), mCommittedText.length());
int matchLength, offset;
/* Backspace over characters that are no longer in the string */
for (matchLength = 0; matchLength < compareLength; )
{
int codePoint = mCommittedText.codePointAt(matchLength);
if (codePoint != text.codePointAt(matchLength))
{
break;
}
matchLength += Character.charCount(codePoint);
}
/* FIXME: This doesn't handle graphemes, like '🌬️' */
for (offset = matchLength; offset < mCommittedText.length(); )
{
int codePoint = mCommittedText.codePointAt(offset);
nativeGenerateScancodeForUnichar('\b');
offset += Character.charCount(codePoint);
}
if (matchLength < text.length())
{
String pendingText = text.subSequence(matchLength, text.length()).toString();
for (offset = 0; offset < pendingText.length(); )
{
int codePoint = pendingText.codePointAt(offset);
if (codePoint == '\n')
{
if (SDLActivity.onNativeSoftReturnKey())
{
return;
}
}
/* Higher code points don't generate simulated scancodes */
if (codePoint < 128)
{
nativeGenerateScancodeForUnichar((char) codePoint);
}
offset += Character.charCount(codePoint);
}
SDLInputConnection.nativeCommitText(pendingText, 0);
}
mCommittedText = text;
}
public static native void nativeCommitText(String text, int newCursorPosition);
public static native void nativeGenerateScancodeForUnichar(char c);
}

View File

@ -0,0 +1,405 @@
package org.libsdl.app;
import android.content.Context;
import android.content.pm.ActivityInfo;
import android.hardware.Sensor;
import android.hardware.SensorEvent;
import android.hardware.SensorEventListener;
import android.hardware.SensorManager;
import android.os.Build;
import android.util.DisplayMetrics;
import android.util.Log;
import android.view.Display;
import android.view.InputDevice;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.Surface;
import android.view.SurfaceHolder;
import android.view.SurfaceView;
import android.view.View;
import android.view.WindowManager;
/**
SDLSurface. This is what we draw on, so we need to know when it's created
in order to do anything useful.
Because of this, that's where we set up the SDL thread
*/
public class SDLSurface extends SurfaceView implements SurfaceHolder.Callback,
View.OnKeyListener, View.OnTouchListener, SensorEventListener {
// Sensors
protected SensorManager mSensorManager;
protected Display mDisplay;
// Keep track of the surface size to normalize touch events
protected float mWidth, mHeight;
// Is SurfaceView ready for rendering
public boolean mIsSurfaceReady;
// Startup
public SDLSurface(Context context) {
super(context);
getHolder().addCallback(this);
setFocusable(true);
setFocusableInTouchMode(true);
requestFocus();
setOnKeyListener(this);
setOnTouchListener(this);
mDisplay = ((WindowManager)context.getSystemService(Context.WINDOW_SERVICE)).getDefaultDisplay();
mSensorManager = (SensorManager)context.getSystemService(Context.SENSOR_SERVICE);
setOnGenericMotionListener(SDLActivity.getMotionListener());
// Some arbitrary defaults to avoid a potential division by zero
mWidth = 1.0f;
mHeight = 1.0f;
mIsSurfaceReady = false;
}
public void handlePause() {
enableSensor(Sensor.TYPE_ACCELEROMETER, false);
}
public void handleResume() {
setFocusable(true);
setFocusableInTouchMode(true);
requestFocus();
setOnKeyListener(this);
setOnTouchListener(this);
enableSensor(Sensor.TYPE_ACCELEROMETER, true);
}
public Surface getNativeSurface() {
return getHolder().getSurface();
}
// Called when we have a valid drawing surface
@Override
public void surfaceCreated(SurfaceHolder holder) {
Log.v("SDL", "surfaceCreated()");
SDLActivity.onNativeSurfaceCreated();
}
// Called when we lose the surface
@Override
public void surfaceDestroyed(SurfaceHolder holder) {
Log.v("SDL", "surfaceDestroyed()");
// Transition to pause, if needed
SDLActivity.mNextNativeState = SDLActivity.NativeState.PAUSED;
SDLActivity.handleNativeState();
mIsSurfaceReady = false;
SDLActivity.onNativeSurfaceDestroyed();
}
// Called when the surface is resized
@Override
public void surfaceChanged(SurfaceHolder holder,
int format, int width, int height) {
Log.v("SDL", "surfaceChanged()");
if (SDLActivity.mSingleton == null) {
return;
}
mWidth = width;
mHeight = height;
int nDeviceWidth = width;
int nDeviceHeight = height;
try
{
if (Build.VERSION.SDK_INT >= 17) {
DisplayMetrics realMetrics = new DisplayMetrics();
mDisplay.getRealMetrics( realMetrics );
nDeviceWidth = realMetrics.widthPixels;
nDeviceHeight = realMetrics.heightPixels;
}
} catch(Exception ignored) {
}
synchronized(SDLActivity.getContext()) {
// In case we're waiting on a size change after going fullscreen, send a notification.
SDLActivity.getContext().notifyAll();
}
Log.v("SDL", "Window size: " + width + "x" + height);
Log.v("SDL", "Device size: " + nDeviceWidth + "x" + nDeviceHeight);
SDLActivity.nativeSetScreenResolution(width, height, nDeviceWidth, nDeviceHeight, mDisplay.getRefreshRate());
SDLActivity.onNativeResize();
// Prevent a screen distortion glitch,
// for instance when the device is in Landscape and a Portrait App is resumed.
boolean skip = false;
int requestedOrientation = SDLActivity.mSingleton.getRequestedOrientation();
if (requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_PORTRAIT || requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT) {
if (mWidth > mHeight) {
skip = true;
}
} else if (requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE || requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE) {
if (mWidth < mHeight) {
skip = true;
}
}
// Special Patch for Square Resolution: Black Berry Passport
if (skip) {
double min = Math.min(mWidth, mHeight);
double max = Math.max(mWidth, mHeight);
if (max / min < 1.20) {
Log.v("SDL", "Don't skip on such aspect-ratio. Could be a square resolution.");
skip = false;
}
}
// Don't skip in MultiWindow.
if (skip) {
if (Build.VERSION.SDK_INT >= 24) {
if (SDLActivity.mSingleton.isInMultiWindowMode()) {
Log.v("SDL", "Don't skip in Multi-Window");
skip = false;
}
}
}
if (skip) {
Log.v("SDL", "Skip .. Surface is not ready.");
mIsSurfaceReady = false;
return;
}
/* If the surface has been previously destroyed by onNativeSurfaceDestroyed, recreate it here */
SDLActivity.onNativeSurfaceChanged();
/* Surface is ready */
mIsSurfaceReady = true;
SDLActivity.mNextNativeState = SDLActivity.NativeState.RESUMED;
SDLActivity.handleNativeState();
}
// Key events
@Override
public boolean onKey(View v, int keyCode, KeyEvent event) {
return SDLActivity.handleKeyEvent(v, keyCode, event, null);
}
// Touch events
@Override
public boolean onTouch(View v, MotionEvent event) {
/* Ref: http://developer.android.com/training/gestures/multi.html */
int touchDevId = event.getDeviceId();
final int pointerCount = event.getPointerCount();
int action = event.getActionMasked();
int pointerFingerId;
int i = -1;
float x,y,p;
/*
* Prevent id to be -1, since it's used in SDL internal for synthetic events
* Appears when using Android emulator, eg:
* adb shell input mouse tap 100 100
* adb shell input touchscreen tap 100 100
*/
if (touchDevId < 0) {
touchDevId -= 1;
}
// 12290 = Samsung DeX mode desktop mouse
// 12290 = 0x3002 = 0x2002 | 0x1002 = SOURCE_MOUSE | SOURCE_TOUCHSCREEN
// 0x2 = SOURCE_CLASS_POINTER
if (event.getSource() == InputDevice.SOURCE_MOUSE || event.getSource() == (InputDevice.SOURCE_MOUSE | InputDevice.SOURCE_TOUCHSCREEN)) {
int mouseButton = 1;
try {
Object object = event.getClass().getMethod("getButtonState").invoke(event);
if (object != null) {
mouseButton = (Integer) object;
}
} catch(Exception ignored) {
}
// We need to check if we're in relative mouse mode and get the axis offset rather than the x/y values
// if we are. We'll leverage our existing mouse motion listener
SDLGenericMotionListener_API12 motionListener = SDLActivity.getMotionListener();
x = motionListener.getEventX(event);
y = motionListener.getEventY(event);
SDLActivity.onNativeMouse(mouseButton, action, x, y, motionListener.inRelativeMode());
} else {
switch(action) {
case MotionEvent.ACTION_MOVE:
for (i = 0; i < pointerCount; i++) {
pointerFingerId = event.getPointerId(i);
x = event.getX(i) / mWidth;
y = event.getY(i) / mHeight;
p = event.getPressure(i);
if (p > 1.0f) {
// may be larger than 1.0f on some devices
// see the documentation of getPressure(i)
p = 1.0f;
}
SDLActivity.onNativeTouch(touchDevId, pointerFingerId, action, x, y, p);
}
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_DOWN:
// Primary pointer up/down, the index is always zero
i = 0;
/* fallthrough */
case MotionEvent.ACTION_POINTER_UP:
case MotionEvent.ACTION_POINTER_DOWN:
// Non primary pointer up/down
if (i == -1) {
i = event.getActionIndex();
}
pointerFingerId = event.getPointerId(i);
x = event.getX(i) / mWidth;
y = event.getY(i) / mHeight;
p = event.getPressure(i);
if (p > 1.0f) {
// may be larger than 1.0f on some devices
// see the documentation of getPressure(i)
p = 1.0f;
}
SDLActivity.onNativeTouch(touchDevId, pointerFingerId, action, x, y, p);
break;
case MotionEvent.ACTION_CANCEL:
for (i = 0; i < pointerCount; i++) {
pointerFingerId = event.getPointerId(i);
x = event.getX(i) / mWidth;
y = event.getY(i) / mHeight;
p = event.getPressure(i);
if (p > 1.0f) {
// may be larger than 1.0f on some devices
// see the documentation of getPressure(i)
p = 1.0f;
}
SDLActivity.onNativeTouch(touchDevId, pointerFingerId, MotionEvent.ACTION_UP, x, y, p);
}
break;
default:
break;
}
}
return true;
}
// Sensor events
public void enableSensor(int sensortype, boolean enabled) {
// TODO: This uses getDefaultSensor - what if we have >1 accels?
if (enabled) {
mSensorManager.registerListener(this,
mSensorManager.getDefaultSensor(sensortype),
SensorManager.SENSOR_DELAY_GAME, null);
} else {
mSensorManager.unregisterListener(this,
mSensorManager.getDefaultSensor(sensortype));
}
}
@Override
public void onAccuracyChanged(Sensor sensor, int accuracy) {
// TODO
}
@Override
public void onSensorChanged(SensorEvent event) {
if (event.sensor.getType() == Sensor.TYPE_ACCELEROMETER) {
// Since we may have an orientation set, we won't receive onConfigurationChanged events.
// We thus should check here.
int newOrientation;
float x, y;
switch (mDisplay.getRotation()) {
case Surface.ROTATION_90:
x = -event.values[1];
y = event.values[0];
newOrientation = SDLActivity.SDL_ORIENTATION_LANDSCAPE;
break;
case Surface.ROTATION_270:
x = event.values[1];
y = -event.values[0];
newOrientation = SDLActivity.SDL_ORIENTATION_LANDSCAPE_FLIPPED;
break;
case Surface.ROTATION_180:
x = -event.values[0];
y = -event.values[1];
newOrientation = SDLActivity.SDL_ORIENTATION_PORTRAIT_FLIPPED;
break;
case Surface.ROTATION_0:
default:
x = event.values[0];
y = event.values[1];
newOrientation = SDLActivity.SDL_ORIENTATION_PORTRAIT;
break;
}
if (newOrientation != SDLActivity.mCurrentOrientation) {
SDLActivity.mCurrentOrientation = newOrientation;
SDLActivity.onNativeOrientationChanged(newOrientation);
}
SDLActivity.onNativeAccel(-x / SensorManager.GRAVITY_EARTH,
y / SensorManager.GRAVITY_EARTH,
event.values[2] / SensorManager.GRAVITY_EARTH);
}
}
// Captured pointer events for API 26.
public boolean onCapturedPointerEvent(MotionEvent event)
{
int action = event.getActionMasked();
float x, y;
switch (action) {
case MotionEvent.ACTION_SCROLL:
x = event.getAxisValue(MotionEvent.AXIS_HSCROLL, 0);
y = event.getAxisValue(MotionEvent.AXIS_VSCROLL, 0);
SDLActivity.onNativeMouse(0, action, x, y, false);
return true;
case MotionEvent.ACTION_HOVER_MOVE:
case MotionEvent.ACTION_MOVE:
x = event.getX(0);
y = event.getY(0);
SDLActivity.onNativeMouse(0, action, x, y, true);
return true;
case MotionEvent.ACTION_BUTTON_PRESS:
case MotionEvent.ACTION_BUTTON_RELEASE:
// Change our action value to what SDL's code expects.
if (action == MotionEvent.ACTION_BUTTON_PRESS) {
action = MotionEvent.ACTION_DOWN;
} else { /* MotionEvent.ACTION_BUTTON_RELEASE */
action = MotionEvent.ACTION_UP;
}
x = event.getX(0);
y = event.getY(0);
int button = event.getButtonState();
SDLActivity.onNativeMouse(button, action, x, y, true);
return true;
}
return false;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 B

View File

@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeColor="#00000000"
android:strokeWidth="1" />
</vector>

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<gradient android:startColor="#000000" android:endColor="#00000000" android:angle="270"/>
</shape>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportHeight="24.0"
android:viewportWidth="24.0">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM13,17h-2v-2h2v2zM13,13h-2L11,7h2v6z" />
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportHeight="24.0"
android:viewportWidth="24.0">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M11,17h2v-6h-2v6zM12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,20c-4.41,0 -8,-3.59 -8,-8s3.59,-8 8,-8 8,3.59 8,8 -3.59,8 -8,8zM11,9h2L13,7h-2v2z" />
</vector>

View File

@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="40dp"
android:height="40dp"
android:viewportHeight="24.0"
android:viewportWidth="24.0">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M22,9.24l-7.19,-0.62L12,2 9.19,8.63 2,9.24l5.46,4.73L5.82,21 12,17.27 18.18,21l-1.63,-7.03L22,9.24zM12,15.4l-3.76,2.27 1,-4.28 -3.32,-2.88 4.38,-0.38L12,6.1l1.71,4.04 4.38,0.38 -3.32,2.88 1,4.28L12,15.4z" />
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="40dp"
android:height="40dp"
android:viewportHeight="24.0"
android:viewportWidth="24.0">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M12,17.27L18.18,21l-1.64,-7.03L22,9.24l-7.19,-0.61L12,2 9.19,8.63 2,9.24l5.46,4.73L5.82,21z" />
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="40dp"
android:height="40dp"
android:viewportHeight="24.0"
android:viewportWidth="24.0">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M22,9.24l-7.19,-0.62L12,2 9.19,8.63 2,9.24l5.46,4.73L5.82,21 12,17.27 18.18,21l-1.63,-7.03L22,9.24zM12,15.4V6.1l1.71,4.04 4.38,0.38 -3.32,2.88 1,4.28L12,15.4z" />
</vector>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:radius="4dp" />
<solid android:color="#A0000000" />
<stroke android:color="@color/accent" android:width="1dp" />
</shape>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<size
android:width="1px"
android:height="1px" />
<solid android:color="@color/accent" />
</shape>

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<layout>
<com.google.android.material.appbar.AppBarLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="@color/bgMain">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/bgMain"
android:elevation="6dp"
app:elevation="6dp"
app:title="@string/launcher_title" />
</com.google.android.material.appbar.AppBarLayout>
</layout>

View File

@ -0,0 +1,76 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_below="@+id/toolbar_include">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="@dimen/side_margin">
<androidx.appcompat.widget.AppCompatTextView
style="@style/VCMI.Text.Header"
android:text="@string/app_name" />
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/about_version_app"
style="@style/VCMI.Text"
android:text="@string/about_version_app" />
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/about_version_launcher"
style="@style/VCMI.Text"
android:text="@string/about_version_launcher" />
</LinearLayout>
<include layout="@layout/inc_separator" />
<androidx.appcompat.widget.AppCompatTextView
style="@style/VCMI.Text.LauncherSection"
android:text="@string/about_section_project" />
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/about_link_portal"
style="@style/VCMI.Entry.Clickable.AboutSimpleEntry"
android:text="@string/about_links_main" />
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/about_link_repo_main"
style="@style/VCMI.Entry.Clickable.AboutSimpleEntry"
android:text="@string/about_links_repo" />
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/about_link_repo_launcher"
style="@style/VCMI.Entry.Clickable.AboutSimpleEntry"
android:text="@string/about_links_repo_launcher" />
<include layout="@layout/inc_separator" />
<androidx.appcompat.widget.AppCompatTextView
style="@style/VCMI.Text.LauncherSection"
android:text="@string/about_section_legal" />
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/about_btn_authors"
style="@style/VCMI.Entry.Clickable.AboutSimpleEntry"
android:text="@string/about_btn_authors" />
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/about_btn_libs"
style="@style/VCMI.Entry.Clickable.AboutSimpleEntry"
android:text="@string/about_btn_libs" />
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/about_btn_privacy"
style="@style/VCMI.Entry.Clickable.AboutSimpleEntry"
android:text="@string/about_btn_privacy" />
</LinearLayout>
</ScrollView>

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/error_message"
style="@style/VCMI.Text"
android:layout_margin="@dimen/side_margin"
android:text="@string/app_name" />
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/error_btn_try_again"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="right"
android:layout_margin="@dimen/side_margin"
android:layout_marginTop="20dp"
android:text="@string/misc_try_again" />
</LinearLayout>

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal">
<RelativeLayout
android:id="@+id/game_outer_frame"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<ProgressBar
android:id="@+id/game_progress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone" />
</FrameLayout>

View File

@ -0,0 +1,106 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_below="@+id/toolbar_include"
android:clipChildren="false"
android:clipToPadding="false">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:orientation="vertical">
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/launcher_version_info"
style="@style/VCMI.Text"
android:padding="@dimen/side_margin"
android:text="@string/app_name" />
<include layout="@layout/inc_separator" />
<androidx.appcompat.widget.AppCompatTextView
style="@style/VCMI.Text.LauncherSection"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:elevation="2dp"
android:text="@string/launcher_section_init"
app:elevation="2dp" />
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<include
android:id="@+id/launcher_btn_start"
layout="@layout/inc_launcher_btn" />
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/launcher_error"
style="@style/VCMI.Text"
android:drawableLeft="@drawable/ic_error"
android:drawablePadding="10dp"
android:gravity="center_vertical"
android:minHeight="80dp"
android:padding="@dimen/side_margin"
android:text="@string/app_name" />
<ProgressBar
android:id="@+id/launcher_progress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center" />
</FrameLayout>
<include
android:id="@+id/launcher_btn_copy"
layout="@layout/inc_launcher_btn" />
<include
android:id="@+id/launcher_btn_export"
layout="@layout/inc_launcher_btn" />
<include layout="@layout/inc_separator" />
<androidx.appcompat.widget.AppCompatTextView
style="@style/VCMI.Text.LauncherSection"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/launcher_section_settings" />
<include
android:id="@+id/launcher_btn_mods"
layout="@layout/inc_launcher_btn" />
<include
android:id="@+id/launcher_btn_res"
layout="@layout/inc_launcher_btn" />
<include
android:id="@+id/launcher_btn_adventure_ai"
layout="@layout/inc_launcher_btn" />
<include
android:id="@+id/launcher_btn_cp"
layout="@layout/inc_launcher_btn" />
<include
android:id="@+id/launcher_btn_pointer_mode"
layout="@layout/inc_launcher_btn" />
<include
android:id="@+id/launcher_btn_pointer_multi"
layout="@layout/inc_launcher_btn" />
<include
android:id="@+id/launcher_btn_volume_sound"
layout="@layout/inc_launcher_slider" />
<include
android:id="@+id/launcher_btn_volume_music"
layout="@layout/inc_launcher_slider" />
</LinearLayout>
</ScrollView>

View File

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
android:id="@+id/mods_data_root"
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_below="@+id/toolbar_include"
android:clipChildren="false"
android:clipToPadding="false">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/mods_recycler"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:listitem="@layout/mods_adapter_item" />
<TextView
android:id="@+id/mods_error_text"
style="@style/VCMI.Text"
android:layout_marginTop="30dp"
android:gravity="center" />
<ProgressBar
android:id="@+id/mods_progress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center" />
</FrameLayout>

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<include
android:id="@+id/toolbar_include"
layout="@layout/inc_toolbar" />
<ViewStub
android:id="@+id/toolbar_wrapper_content_stub"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_below="@id/toolbar_include" />
</RelativeLayout>
</layout>

View File

@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<androidx.appcompat.widget.AppCompatTextView
style="@style/VCMI.Text.LauncherSection"
android:text="@string/dialog_authors_vcmi" />
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/dialog_authors_vcmi"
style="@style/VCMI.Text"
android:padding="@dimen/side_margin" />
<include layout="@layout/inc_separator" />
<!-- TODO should this be separate or just merged with vcmi authors? -->
<androidx.appcompat.widget.AppCompatTextView
style="@style/VCMI.Text.LauncherSection"
android:text="@string/dialog_authors_launcher" />
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/dialog_authors_launcher"
style="@style/VCMI.Text"
android:padding="@dimen/side_margin" />
</LinearLayout>
</ScrollView>

View File

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android">
<data>
<variable
name="title"
type="java.lang.String" />
<variable
name="description"
type="java.lang.String" />
</data>
<RelativeLayout
style="@style/VCMI.Entry.Clickable"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_centerVertical="true"
android:orientation="vertical">
<TextView
android:id="@+id/inc_launcher_btn_main"
style="@style/VCMI.Text.LauncherEntry"
android:text="@{title}" />
<TextView
android:id="@+id/inc_launcher_btn_sub"
style="@style/VCMI.Text.LauncherEntry.Sub"
android:text="@{description}" />
</LinearLayout>
</RelativeLayout>
</layout>

View File

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android">
<RelativeLayout
style="@style/VCMI.Entry"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_centerVertical="true"
android:orientation="vertical">
<TextView
android:id="@+id/inc_launcher_btn_main"
style="@style/VCMI.Text.LauncherEntry"
android:text="@string/app_name" />
<androidx.appcompat.widget.AppCompatSeekBar
android:id="@+id/inc_launcher_btn_slider"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="8dp"
android:paddingBottom="8dp" />
</LinearLayout>
</RelativeLayout>
</layout>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android">
<View
android:layout_width="match_parent"
android:layout_height="1px"
android:background="@color/separator" />
</layout>

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