diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 000000000..e4e18cdc0 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,9 @@ +*.iml +.gradle +/local.properties +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/android/GeneratedVersion.java.in b/android/GeneratedVersion.java.in new file mode 100644 index 000000000..609fa5523 --- /dev/null +++ b/android/GeneratedVersion.java.in @@ -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@"; +} \ No newline at end of file diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 000000000..880bdfac3 --- /dev/null +++ b/android/build.gradle @@ -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 +} diff --git a/android/defs.gradle b/android/defs.gradle new file mode 100644 index 000000000..736cbfe49 --- /dev/null +++ b/android/defs.gradle @@ -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}") +} diff --git a/android/defs.gradle.properties b/android/defs.gradle.properties new file mode 100644 index 000000000..e69de29bb diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 000000000..01b80d70c --- /dev/null +++ b/android/gradle.properties @@ -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 diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..e708b1c02 Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..0fd7e9b56 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 000000000..4f906e0c8 --- /dev/null +++ b/android/gradlew @@ -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" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 000000000..ac1b06f93 --- /dev/null +++ b/android/gradlew.bat @@ -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 diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 000000000..708ab6991 --- /dev/null +++ b/android/settings.gradle @@ -0,0 +1,9 @@ +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} +rootProject.name = "VCMI" +include ':vcmi-app' diff --git a/android/vcmi-app/.gitignore b/android/vcmi-app/.gitignore new file mode 100644 index 000000000..796b96d1c --- /dev/null +++ b/android/vcmi-app/.gitignore @@ -0,0 +1 @@ +/build diff --git a/android/vcmi-app/build.gradle b/android/vcmi-app/build.gradle new file mode 100644 index 000000000..351aa71a8 --- /dev/null +++ b/android/vcmi-app/build.gradle @@ -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' +} diff --git a/android/vcmi-app/proguard-rules.pro b/android/vcmi-app/proguard-rules.pro new file mode 100644 index 000000000..481bb4348 --- /dev/null +++ b/android/vcmi-app/proguard-rules.pro @@ -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 \ No newline at end of file diff --git a/android/vcmi-app/src/main/AndroidManifest.xml b/android/vcmi-app/src/main/AndroidManifest.xml new file mode 100644 index 000000000..dd39d74c3 --- /dev/null +++ b/android/vcmi-app/src/main/AndroidManifest.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/vcmi-app/src/main/java/eu/vcmi/vcmi/ActivityAbout.java b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/ActivityAbout.java new file mode 100644 index 000000000..b4f3bd7c5 --- /dev/null +++ b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/ActivityAbout.java @@ -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); + } +} diff --git a/android/vcmi-app/src/main/java/eu/vcmi/vcmi/ActivityBase.java b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/ActivityBase.java new file mode 100644 index 000000000..ff67ca117 --- /dev/null +++ b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/ActivityBase.java @@ -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); + } + } + } +} diff --git a/android/vcmi-app/src/main/java/eu/vcmi/vcmi/ActivityError.java b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/ActivityError.java new file mode 100644 index 000000000..d11822ada --- /dev/null +++ b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/ActivityError.java @@ -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(); + } + } +} diff --git a/android/vcmi-app/src/main/java/eu/vcmi/vcmi/ActivityLauncher.java b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/ActivityLauncher.java new file mode 100644 index 000000000..d5f6a5b00 --- /dev/null +++ b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/ActivityLauncher.java @@ -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> mActualSettings = new ArrayList<>(); + private View mProgress; + private TextView mErrorMessage; + private Config mConfig; + private LauncherSettingController mCtrlScreenRes; + private LauncherSettingController mCtrlCodepage; + private LauncherSettingController mCtrlPointerMode; + private LauncherSettingController mCtrlStart; + private LauncherSettingController mCtrlPointerMulti; + private LauncherSettingController mCtrlSoundVol; + private LauncherSettingController mCtrlMusicVol; + private LauncherSettingController 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 void updateCtrlConfig( + final LauncherSettingController 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); + } +} diff --git a/android/vcmi-app/src/main/java/eu/vcmi/vcmi/ActivityMods.java b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/ActivityMods.java new file mode 100644 index 000000000..8d9934ec4 --- /dev/null +++ b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/ActivityMods.java @@ -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 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> 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 + { + @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]); + } + } + } +} diff --git a/android/vcmi-app/src/main/java/eu/vcmi/vcmi/ActivityWithToolbar.java b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/ActivityWithToolbar.java new file mode 100644 index 000000000..e623fda69 --- /dev/null +++ b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/ActivityWithToolbar.java @@ -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); + } + } + } +} diff --git a/android/vcmi-app/src/main/java/eu/vcmi/vcmi/Config.java b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/Config.java new file mode 100644 index 000000000..65bd3093b --- /dev/null +++ b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/Config.java @@ -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 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(); + } +} \ No newline at end of file diff --git a/android/vcmi-app/src/main/java/eu/vcmi/vcmi/Const.java b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/Const.java new file mode 100644 index 000000000..cb02e2d83 --- /dev/null +++ b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/Const.java @@ -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"; +} diff --git a/android/vcmi-app/src/main/java/eu/vcmi/vcmi/NativeMethods.java b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/NativeMethods.java new file mode 100644 index 000000000..a2f1f7e67 --- /dev/null +++ b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/NativeMethods.java @@ -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 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; + } +} diff --git a/android/vcmi-app/src/main/java/eu/vcmi/vcmi/ServerService.java b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/ServerService.java new file mode 100644 index 000000000..0afe30161 --- /dev/null +++ b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/ServerService.java @@ -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 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; + } + } +} diff --git a/android/vcmi-app/src/main/java/eu/vcmi/vcmi/Storage.java b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/Storage.java new file mode 100644 index 000000000..9986f890e --- /dev/null +++ b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/Storage.java @@ -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(); + } +} diff --git a/android/vcmi-app/src/main/java/eu/vcmi/vcmi/VcmiSDLActivity.java b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/VcmiSDLActivity.java new file mode 100644 index 000000000..9c0fb3852 --- /dev/null +++ b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/VcmiSDLActivity.java @@ -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); + } + } + } +} \ No newline at end of file diff --git a/android/vcmi-app/src/main/java/eu/vcmi/vcmi/content/AsyncLauncherInitialization.java b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/content/AsyncLauncherInitialization.java new file mode 100644 index 000000000..95d8983bd --- /dev/null +++ b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/content/AsyncLauncherInitialization.java @@ -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 +{ + private final WeakReference 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, ""); + } + } +} diff --git a/android/vcmi-app/src/main/java/eu/vcmi/vcmi/content/DialogAuthors.java b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/content/DialogAuthors.java new file mode 100644 index 000000000..84e555fca --- /dev/null +++ b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/content/DialogAuthors.java @@ -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); + } + } +} diff --git a/android/vcmi-app/src/main/java/eu/vcmi/vcmi/content/ModBaseViewHolder.java b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/content/ModBaseViewHolder.java new file mode 100644 index 000000000..0102b330a --- /dev/null +++ b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/content/ModBaseViewHolder.java @@ -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); + } +} diff --git a/android/vcmi-app/src/main/java/eu/vcmi/vcmi/content/ModsAdapter.java b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/content/ModsAdapter.java new file mode 100644 index 000000000..bc43eccc1 --- /dev/null +++ b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/content/ModsAdapter.java @@ -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 +{ + 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 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 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 mods) + { + mDataset.clear(); + + List 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(); + } + } +} diff --git a/android/vcmi-app/src/main/java/eu/vcmi/vcmi/content/ModsViewHolder.java b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/content/ModsViewHolder.java new file mode 100644 index 000000000..51144ec38 --- /dev/null +++ b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/content/ModsViewHolder.java @@ -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); + } +} diff --git a/android/vcmi-app/src/main/java/eu/vcmi/vcmi/mods/VCMIMod.java b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/mods/VCMIMod.java new file mode 100644 index 000000000..2c813fdae --- /dev/null +++ b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/mods/VCMIMod.java @@ -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 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 loadSubmods(final List modsList) throws IOException, JSONException + { + final Map 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 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 submods() + { + final ArrayList ret = new ArrayList<>(); + + ret.addAll(mSubmods.values()); + + Collections.sort(ret, new Comparator() + { + @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; + } +} diff --git a/android/vcmi-app/src/main/java/eu/vcmi/vcmi/mods/VCMIModContainer.java b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/mods/VCMIModContainer.java new file mode 100644 index 000000000..24fecd6ee --- /dev/null +++ b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/mods/VCMIModContainer.java @@ -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 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 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); + } +} diff --git a/android/vcmi-app/src/main/java/eu/vcmi/vcmi/mods/VCMIModsRepo.java b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/mods/VCMIModsRepo.java new file mode 100644 index 000000000..900bedbe8 --- /dev/null +++ b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/mods/VCMIModsRepo.java @@ -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 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> response); + void onError(final int code); + } + + private class AsyncLoadRepo extends AsyncRequest> + { + @Override + protected ServerResponse> doInBackground(final String... params) + { + ServerResponse> serverResponse = sendRequest(params[0]); + if (serverResponse.isValid()) + { + final List 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> 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> response) + { + if (response.isValid()) + { + mModsList.clear(); + mModsList.addAll(response.mContent); + mCallback.onSuccess(response); + } + else + { + mCallback.onError(response.mCode); + } + } + } +} diff --git a/android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/AdventureAiController.java b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/AdventureAiController.java new file mode 100644 index 000000000..39059099c --- /dev/null +++ b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/AdventureAiController.java @@ -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 +{ + public AdventureAiController(final AppCompatActivity activity) + { + super(activity); + } + + @Override + protected LauncherSettingDialog 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(); + } +} diff --git a/android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/AdventureAiSelectionDialog.java b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/AdventureAiSelectionDialog.java new file mode 100644 index 000000000..e1cd50332 --- /dev/null +++ b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/AdventureAiSelectionDialog.java @@ -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 +{ + private static final List 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; + } +} diff --git a/android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/CodepageSettingController.java b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/CodepageSettingController.java new file mode 100644 index 000000000..93d319df7 --- /dev/null +++ b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/CodepageSettingController.java @@ -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 +{ + public CodepageSettingController(final AppCompatActivity activity) + { + super(activity); + } + + @Override + protected LauncherSettingDialog 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); + } +} diff --git a/android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/CodepageSettingDialog.java b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/CodepageSettingDialog.java new file mode 100644 index 000000000..3656d3ba9 --- /dev/null +++ b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/CodepageSettingDialog.java @@ -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 +{ + private static final List 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; + } +} diff --git a/android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/CopyDataController.java b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/CopyDataController.java new file mode 100644 index 000000000..9acf5e0a2 --- /dev/null +++ b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/CopyDataController.java @@ -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 +{ + 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 + { + 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 allowedFolders = new ArrayList(); + + 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 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; + } + } +} + diff --git a/android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/DoubleConfig.java b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/DoubleConfig.java new file mode 100644 index 000000000..03e7401b1 --- /dev/null +++ b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/DoubleConfig.java @@ -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; + } +} diff --git a/android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/ExportDataController.java b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/ExportDataController.java new file mode 100644 index 000000000..b799d10c1 --- /dev/null +++ b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/ExportDataController.java @@ -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 +{ + 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 + { + 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; + } + } +} diff --git a/android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/LauncherSettingController.java b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/LauncherSettingController.java new file mode 100644 index 000000000..ef1311588 --- /dev/null +++ b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/LauncherSettingController.java @@ -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 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 init(final int rootViewResId) + { + return init(rootViewResId, null); + } + + public final LauncherSettingController 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); + } +} diff --git a/android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/LauncherSettingDialog.java b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/LauncherSettingDialog.java new file mode 100644 index 000000000..7e5386c80 --- /dev/null +++ b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/LauncherSettingDialog.java @@ -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 extends DialogFragment +{ + protected final List mDataset; + private IOnItemChosen mObserver; + + protected LauncherSettingDialog(final List dataset) + { + mDataset = dataset; + } + + public void observe(final IOnItemChosen observer) + { + mObserver = observer; + } + + protected abstract CharSequence itemName(T item); + + protected abstract int dialogTitleResId(); + + @NonNull + @Override + public Dialog onCreateDialog(final Bundle savedInstanceState) + { + List 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 + { + void onItemChosen(V item); + } +} diff --git a/android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/LauncherSettingWithDialogController.java b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/LauncherSettingWithDialogController.java new file mode 100644 index 000000000..0a670de64 --- /dev/null +++ b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/LauncherSettingWithDialogController.java @@ -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 extends LauncherSettingController + implements LauncherSettingDialog.IOnItemChosen +{ + 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 dialog = dialog(); + dialog.observe(this); // TODO rebinding dialogs on activity config changes + dialog.show(mActivity.getSupportFragmentManager(), SETTING_DIALOG_ID); + } + + protected abstract LauncherSettingDialog dialog(); +} diff --git a/android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/LauncherSettingWithSliderController.java b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/LauncherSettingWithSliderController.java new file mode 100644 index 000000000..af5f21f30 --- /dev/null +++ b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/LauncherSettingWithSliderController.java @@ -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 extends LauncherSettingController +{ + 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) + { + + } + } +} diff --git a/android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/ModsBtnController.java b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/ModsBtnController.java new file mode 100644 index 000000000..ace923401 --- /dev/null +++ b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/ModsBtnController.java @@ -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 +{ + 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); + } +} diff --git a/android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/MusicSettingController.java b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/MusicSettingController.java new file mode 100644 index 000000000..96ad805f5 --- /dev/null +++ b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/MusicSettingController.java @@ -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 +{ + 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); + } +} diff --git a/android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/PointerModeSettingController.java b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/PointerModeSettingController.java new file mode 100644 index 000000000..b09c36312 --- /dev/null +++ b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/PointerModeSettingController.java @@ -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 +{ + public PointerModeSettingController(final AppCompatActivity activity) + { + super(activity); + } + + @Override + protected LauncherSettingDialog 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; + } + } +} diff --git a/android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/PointerModeSettingDialog.java b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/PointerModeSettingDialog.java new file mode 100644 index 000000000..6338db1a9 --- /dev/null +++ b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/PointerModeSettingDialog.java @@ -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 +{ + private static final List 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); + } + +} diff --git a/android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/PointerMultiplierSettingController.java b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/PointerMultiplierSettingController.java new file mode 100644 index 000000000..f2824cfa9 --- /dev/null +++ b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/PointerMultiplierSettingController.java @@ -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 +{ + public PointerMultiplierSettingController(final AppCompatActivity activity) + { + super(activity); + } + + @Override + protected LauncherSettingDialog 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); + } +} diff --git a/android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/PointerMultiplierSettingDialog.java b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/PointerMultiplierSettingDialog.java new file mode 100644 index 000000000..23b786ba5 --- /dev/null +++ b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/PointerMultiplierSettingDialog.java @@ -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 +{ + private static final List 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); + } +} diff --git a/android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/ScreenResSettingController.java b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/ScreenResSettingController.java new file mode 100644 index 000000000..378df6b91 --- /dev/null +++ b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/ScreenResSettingController.java @@ -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 +{ + public ScreenResSettingController(final AppCompatActivity activity) + { + super(activity); + } + + @Override + protected LauncherSettingDialog 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; + } + } +} diff --git a/android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/ScreenResSettingDialog.java b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/ScreenResSettingDialog.java new file mode 100644 index 000000000..308764698 --- /dev/null +++ b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/ScreenResSettingDialog.java @@ -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 +{ + 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 loadResolutions(Activity activity) + { + List availableResolutions = new ArrayList<>(); + + try + { + File modsFolder = new File(Storage.getVcmiDataDir(activity), "Mods"); + Queue folders = new ArrayDeque(); + 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; + } +} diff --git a/android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/SoundSettingController.java b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/SoundSettingController.java new file mode 100644 index 000000000..e9b34fe43 --- /dev/null +++ b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/SoundSettingController.java @@ -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 +{ + 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); + } +} diff --git a/android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/StartGameController.java b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/StartGameController.java new file mode 100644 index 000000000..4121d8703 --- /dev/null +++ b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/StartGameController.java @@ -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 +{ + 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); + } +} diff --git a/android/vcmi-app/src/main/java/eu/vcmi/vcmi/util/AsyncRequest.java b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/util/AsyncRequest.java new file mode 100644 index 000000000..83c5e7fcf --- /dev/null +++ b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/util/AsyncRequest.java @@ -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 extends AsyncTask> +{ + @TargetApi(Const.SUPPRESS_TRY_WITH_RESOURCES_WARNING) + protected ServerResponse 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); + } + +} diff --git a/android/vcmi-app/src/main/java/eu/vcmi/vcmi/util/FileUtil.java b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/util/FileUtil.java new file mode 100644 index 000000000..e64dd3afa --- /dev/null +++ b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/util/FileUtil.java @@ -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; + } + } +} diff --git a/android/vcmi-app/src/main/java/eu/vcmi/vcmi/util/IZipProgressReporter.java b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/util/IZipProgressReporter.java new file mode 100644 index 000000000..93479c226 --- /dev/null +++ b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/util/IZipProgressReporter.java @@ -0,0 +1,8 @@ +package eu.vcmi.vcmi.util; + +import java.io.File; + +public interface IZipProgressReporter +{ + void onUnpacked(File newFile); +} diff --git a/android/vcmi-app/src/main/java/eu/vcmi/vcmi/util/InstallModAsync.java b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/util/InstallModAsync.java new file mode 100644 index 000000000..4d463b6c7 --- /dev/null +++ b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/util/InstallModAsync.java @@ -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 + 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); + } +} \ No newline at end of file diff --git a/android/vcmi-app/src/main/java/eu/vcmi/vcmi/util/LegacyConfigReader.java b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/util/LegacyConfigReader.java new file mode 100644 index 000000000..d6985bd56 --- /dev/null +++ b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/util/LegacyConfigReader.java @@ -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; + } +} diff --git a/android/vcmi-app/src/main/java/eu/vcmi/vcmi/util/LibsLoader.java b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/util/LibsLoader.java new file mode 100644 index 000000000..4d82af02c --- /dev/null +++ b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/util/LibsLoader.java @@ -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(); + } +} diff --git a/android/vcmi-app/src/main/java/eu/vcmi/vcmi/util/Log.java b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/util/Log.java new file mode 100644 index 000000000..c18e3c803 --- /dev/null +++ b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/util/Log.java @@ -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)); + } +} diff --git a/android/vcmi-app/src/main/java/eu/vcmi/vcmi/util/ServerResponse.java b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/util/ServerResponse.java new file mode 100644 index 000000000..45d4fbfa0 --- /dev/null +++ b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/util/ServerResponse.java @@ -0,0 +1,30 @@ +package eu.vcmi.vcmi.util; + +/** + * @author F + */ +public class ServerResponse +{ + 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); + } +} diff --git a/android/vcmi-app/src/main/java/eu/vcmi/vcmi/util/SharedPrefs.java b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/util/SharedPrefs.java new file mode 100644 index 000000000..c2f09463e --- /dev/null +++ b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/util/SharedPrefs.java @@ -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 > void saveEnum(final String name, final T value) + { + mPrefs.edit().putInt(name, value.ordinal()).apply(); + log(name, value, true); + } + + @SuppressWarnings("unchecked") + public > 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 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; + } +} diff --git a/android/vcmi-app/src/main/java/eu/vcmi/vcmi/util/Utils.java b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/util/Utils.java new file mode 100644 index 000000000..95906f84e --- /dev/null +++ b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/util/Utils.java @@ -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; + } +} diff --git a/android/vcmi-app/src/main/java/eu/vcmi/vcmi/viewmodels/ObservableViewModel.java b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/viewmodels/ObservableViewModel.java new file mode 100644 index 000000000..81fd98bb3 --- /dev/null +++ b/android/vcmi-app/src/main/java/eu/vcmi/vcmi/viewmodels/ObservableViewModel.java @@ -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); + } +} \ No newline at end of file diff --git a/android/vcmi-app/src/main/java/org/libsdl/app/DummyEdit.java b/android/vcmi-app/src/main/java/org/libsdl/app/DummyEdit.java new file mode 100644 index 000000000..6c504296c --- /dev/null +++ b/android/vcmi-app/src/main/java/org/libsdl/app/DummyEdit.java @@ -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; + } +} diff --git a/android/vcmi-app/src/main/java/org/libsdl/app/HIDDevice.java b/android/vcmi-app/src/main/java/org/libsdl/app/HIDDevice.java new file mode 100644 index 000000000..955df5d14 --- /dev/null +++ b/android/vcmi-app/src/main/java/org/libsdl/app/HIDDevice.java @@ -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(); +} diff --git a/android/vcmi-app/src/main/java/org/libsdl/app/HIDDeviceBLESteamController.java b/android/vcmi-app/src/main/java/org/libsdl/app/HIDDeviceBLESteamController.java new file mode 100644 index 000000000..65c5a4237 --- /dev/null +++ b/android/vcmi-app/src/main/java/org/libsdl/app/HIDDeviceBLESteamController.java @@ -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 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(); + 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(); + } + +} + diff --git a/android/vcmi-app/src/main/java/org/libsdl/app/HIDDeviceManager.java b/android/vcmi-app/src/main/java/org/libsdl/app/HIDDeviceManager.java new file mode 100644 index 000000000..cf3c9267f --- /dev/null +++ b/android/vcmi-app/src/main/java/org/libsdl/app/HIDDeviceManager.java @@ -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 mDevicesById = new HashMap(); + private HashMap mBluetoothDevices = new HashMap(); + private int mNextDeviceId = 0; + private SharedPreferences mSharedPreferences = null; + private boolean mIsChromebook = false; + private UsbManager mUsbManager; + private Handler mHandler; + private BluetoothManager mBluetoothManager; + private List 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 devices = new ArrayList(); + 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(); + + // 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 disconnected = new ArrayList(); + ArrayList connected = new ArrayList(); + + List 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); +} diff --git a/android/vcmi-app/src/main/java/org/libsdl/app/HIDDeviceUSB.java b/android/vcmi-app/src/main/java/org/libsdl/app/HIDDeviceUSB.java new file mode 100644 index 000000000..d20fe80bc --- /dev/null +++ b/android/vcmi-app/src/main/java/org/libsdl/app/HIDDeviceUSB.java @@ -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); + } + } + } + } + } +} diff --git a/android/vcmi-app/src/main/java/org/libsdl/app/SDL.java b/android/vcmi-app/src/main/java/org/libsdl/app/SDL.java new file mode 100644 index 000000000..39fc26428 --- /dev/null +++ b/android/vcmi-app/src/main/java/org/libsdl/app/SDL.java @@ -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; +} diff --git a/android/vcmi-app/src/main/java/org/libsdl/app/SDLActivity.java b/android/vcmi-app/src/main/java/org/libsdl/app/SDLActivity.java new file mode 100644 index 000000000..e8bfdf992 --- /dev/null +++ b/android/vcmi-app/src/main/java/org/libsdl/app/SDLActivity.java @@ -0,0 +1,1931 @@ +package org.libsdl.app; + +import android.app.Activity; +import android.app.AlertDialog; +import android.app.Dialog; +import android.app.UiModeManager; +import android.content.ClipboardManager; +import android.content.ClipData; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.pm.ActivityInfo; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +import android.content.res.Configuration; +import android.graphics.Bitmap; +import android.graphics.Color; +import android.graphics.PorterDuff; +import android.graphics.drawable.Drawable; +import android.hardware.Sensor; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.Message; +import android.text.Editable; +import android.text.InputType; +import android.text.Selection; +import android.util.DisplayMetrics; +import android.util.Log; +import android.util.SparseArray; +import android.view.Display; +import android.view.Gravity; +import android.view.InputDevice; +import android.view.KeyEvent; +import android.view.PointerIcon; +import android.view.Surface; +import android.view.View; +import android.view.ViewGroup; +import android.view.Window; +import android.view.WindowManager; +import android.view.inputmethod.BaseInputConnection; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputConnection; +import android.view.inputmethod.InputMethodManager; +import android.widget.Button; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.RelativeLayout; +import android.widget.TextView; +import android.widget.Toast; + +import java.util.Hashtable; +import java.util.Locale; + +import eu.vcmi.vcmi.util.LibsLoader; + + +/** + SDL Activity +*/ +public class SDLActivity extends Activity implements View.OnSystemUiVisibilityChangeListener { + private static final String TAG = "SDL"; + private static final int SDL_MAJOR_VERSION = 2; + private static final int SDL_MINOR_VERSION = 26; + private static final int SDL_MICRO_VERSION = 1; +/* + // Display InputType.SOURCE/CLASS of events and devices + // + // SDLActivity.debugSource(device.getSources(), "device[" + device.getName() + "]"); + // SDLActivity.debugSource(event.getSource(), "event"); + public static void debugSource(int sources, String prefix) { + int s = sources; + int s_copy = sources; + String cls = ""; + String src = ""; + int tst = 0; + int FLAG_TAINTED = 0x80000000; + + if ((s & InputDevice.SOURCE_CLASS_BUTTON) != 0) cls += " BUTTON"; + if ((s & InputDevice.SOURCE_CLASS_JOYSTICK) != 0) cls += " JOYSTICK"; + if ((s & InputDevice.SOURCE_CLASS_POINTER) != 0) cls += " POINTER"; + if ((s & InputDevice.SOURCE_CLASS_POSITION) != 0) cls += " POSITION"; + if ((s & InputDevice.SOURCE_CLASS_TRACKBALL) != 0) cls += " TRACKBALL"; + + + int s2 = s_copy & ~InputDevice.SOURCE_ANY; // keep class bits + s2 &= ~( InputDevice.SOURCE_CLASS_BUTTON + | InputDevice.SOURCE_CLASS_JOYSTICK + | InputDevice.SOURCE_CLASS_POINTER + | InputDevice.SOURCE_CLASS_POSITION + | InputDevice.SOURCE_CLASS_TRACKBALL); + + if (s2 != 0) cls += "Some_Unkown"; + + s2 = s_copy & InputDevice.SOURCE_ANY; // keep source only, no class; + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + tst = InputDevice.SOURCE_BLUETOOTH_STYLUS; + if ((s & tst) == tst) src += " BLUETOOTH_STYLUS"; + s2 &= ~tst; + } + + tst = InputDevice.SOURCE_DPAD; + if ((s & tst) == tst) src += " DPAD"; + s2 &= ~tst; + + tst = InputDevice.SOURCE_GAMEPAD; + if ((s & tst) == tst) src += " GAMEPAD"; + s2 &= ~tst; + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + tst = InputDevice.SOURCE_HDMI; + if ((s & tst) == tst) src += " HDMI"; + s2 &= ~tst; + } + + tst = InputDevice.SOURCE_JOYSTICK; + if ((s & tst) == tst) src += " JOYSTICK"; + s2 &= ~tst; + + tst = InputDevice.SOURCE_KEYBOARD; + if ((s & tst) == tst) src += " KEYBOARD"; + s2 &= ~tst; + + tst = InputDevice.SOURCE_MOUSE; + if ((s & tst) == tst) src += " MOUSE"; + s2 &= ~tst; + + if (Build.VERSION.SDK_INT >= 26) { + tst = InputDevice.SOURCE_MOUSE_RELATIVE; + if ((s & tst) == tst) src += " MOUSE_RELATIVE"; + s2 &= ~tst; + + tst = InputDevice.SOURCE_ROTARY_ENCODER; + if ((s & tst) == tst) src += " ROTARY_ENCODER"; + s2 &= ~tst; + } + tst = InputDevice.SOURCE_STYLUS; + if ((s & tst) == tst) src += " STYLUS"; + s2 &= ~tst; + + tst = InputDevice.SOURCE_TOUCHPAD; + if ((s & tst) == tst) src += " TOUCHPAD"; + s2 &= ~tst; + + tst = InputDevice.SOURCE_TOUCHSCREEN; + if ((s & tst) == tst) src += " TOUCHSCREEN"; + s2 &= ~tst; + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) { + tst = InputDevice.SOURCE_TOUCH_NAVIGATION; + if ((s & tst) == tst) src += " TOUCH_NAVIGATION"; + s2 &= ~tst; + } + + tst = InputDevice.SOURCE_TRACKBALL; + if ((s & tst) == tst) src += " TRACKBALL"; + s2 &= ~tst; + + tst = InputDevice.SOURCE_ANY; + if ((s & tst) == tst) src += " ANY"; + s2 &= ~tst; + + if (s == FLAG_TAINTED) src += " FLAG_TAINTED"; + s2 &= ~FLAG_TAINTED; + + if (s2 != 0) src += " Some_Unkown"; + + Log.v(TAG, prefix + "int=" + s_copy + " CLASS={" + cls + " } source(s):" + src); + } +*/ + + public static boolean mIsResumedCalled, mHasFocus; + public static final boolean mHasMultiWindow = (Build.VERSION.SDK_INT >= 24); + + // Cursor types + // private static final int SDL_SYSTEM_CURSOR_NONE = -1; + private static final int SDL_SYSTEM_CURSOR_ARROW = 0; + private static final int SDL_SYSTEM_CURSOR_IBEAM = 1; + private static final int SDL_SYSTEM_CURSOR_WAIT = 2; + private static final int SDL_SYSTEM_CURSOR_CROSSHAIR = 3; + private static final int SDL_SYSTEM_CURSOR_WAITARROW = 4; + private static final int SDL_SYSTEM_CURSOR_SIZENWSE = 5; + private static final int SDL_SYSTEM_CURSOR_SIZENESW = 6; + private static final int SDL_SYSTEM_CURSOR_SIZEWE = 7; + private static final int SDL_SYSTEM_CURSOR_SIZENS = 8; + private static final int SDL_SYSTEM_CURSOR_SIZEALL = 9; + private static final int SDL_SYSTEM_CURSOR_NO = 10; + private static final int SDL_SYSTEM_CURSOR_HAND = 11; + + protected static final int SDL_ORIENTATION_UNKNOWN = 0; + protected static final int SDL_ORIENTATION_LANDSCAPE = 1; + protected static final int SDL_ORIENTATION_LANDSCAPE_FLIPPED = 2; + protected static final int SDL_ORIENTATION_PORTRAIT = 3; + protected static final int SDL_ORIENTATION_PORTRAIT_FLIPPED = 4; + + protected static int mCurrentOrientation; + protected static Locale mCurrentLocale; + + // Handle the state of the native layer + public enum NativeState { + INIT, RESUMED, PAUSED + } + + public static NativeState mNextNativeState; + public static NativeState mCurrentNativeState; + + /** If shared libraries (e.g. SDL or the native application) could not be loaded. */ + public static boolean mBrokenLibraries = true; + + // Main components + protected static SDLActivity mSingleton; + protected static SDLSurface mSurface; + protected static DummyEdit mTextEdit; + protected static boolean mScreenKeyboardShown; + protected static ViewGroup mLayout; + protected static SDLClipboardHandler mClipboardHandler; + protected static Hashtable mCursors; + protected static int mLastCursorID; + protected static SDLGenericMotionListener_API12 mMotionListener; + protected static HIDDeviceManager mHIDDeviceManager; + + // This is what SDL runs in. It invokes SDL_main(), eventually + protected static Thread mSDLThread; + + protected static SDLGenericMotionListener_API12 getMotionListener() { + if (mMotionListener == null) { + if (Build.VERSION.SDK_INT >= 26) { + mMotionListener = new SDLGenericMotionListener_API26(); + } else if (Build.VERSION.SDK_INT >= 24) { + mMotionListener = new SDLGenericMotionListener_API24(); + } else { + mMotionListener = new SDLGenericMotionListener_API12(); + } + } + + return mMotionListener; + } + + /** + * This method returns the name of the shared object with the application entry point + * It can be overridden by derived classes. + */ + protected String getMainSharedObject() { + String library = "libvcmi-client.so"; + + return getContext().getApplicationInfo().nativeLibraryDir + "/" + library; + } + + /** + * This method returns the name of the application entry point + * It can be overridden by derived classes. + */ + protected String getMainFunction() { + return "SDL_main"; + } + + /** + * This method is called by SDL before loading the native shared libraries. + * It can be overridden to provide names of shared libraries to be loaded. + * The default implementation returns the defaults. It never returns null. + * An array returned by a new implementation must at least contain "SDL2". + * Also keep in mind that the order the libraries are loaded may matter. + * @return names of shared libraries to be loaded (e.g. "SDL2", "main"). + */ + protected String[] getLibraries() { + return new String[] { + "SDL2", + // "SDL2_image", + // "SDL2_mixer", + // "SDL2_net", + // "SDL2_ttf", + "main" + }; + } + + // Load the .so + public void loadLibraries() { + for (String lib : getLibraries()) { + SDL.loadLibrary(lib); + } + } + + /** + * This method is called by SDL before starting the native application thread. + * It can be overridden to provide the arguments after the application name. + * The default implementation returns an empty array. It never returns null. + * @return arguments for the native application. + */ + protected String[] getArguments() { + return new String[0]; + } + + public static void initialize() { + // The static nature of the singleton and Android quirkyness force us to initialize everything here + // Otherwise, when exiting the app and returning to it, these variables *keep* their pre exit values + mSingleton = null; + mSurface = null; + mTextEdit = null; + mLayout = null; + mClipboardHandler = null; + mCursors = new Hashtable(); + mLastCursorID = 0; + mSDLThread = null; + mIsResumedCalled = false; + mHasFocus = true; + mNextNativeState = NativeState.INIT; + mCurrentNativeState = NativeState.INIT; + } + + protected SDLSurface createSDLSurface(Context context) { + return new SDLSurface(context); + } + + // Setup + @Override + protected void onCreate(Bundle savedInstanceState) { + Log.v(TAG, "Device: " + Build.DEVICE); + Log.v(TAG, "Model: " + Build.MODEL); + Log.v(TAG, "onCreate()"); + super.onCreate(savedInstanceState); + + try { + Thread.currentThread().setName("SDLActivity"); + } catch (Exception e) { + Log.v(TAG, "modify thread properties failed " + e.toString()); + } + + // Load shared libraries + String errorMsgBrokenLib = ""; + try { + loadLibraries(); + mBrokenLibraries = false; /* success */ + } catch(UnsatisfiedLinkError e) { + System.err.println(e.getMessage()); + mBrokenLibraries = true; + errorMsgBrokenLib = e.getMessage(); + } catch(Exception e) { + System.err.println(e.getMessage()); + mBrokenLibraries = true; + errorMsgBrokenLib = e.getMessage(); + } + + if (!mBrokenLibraries) { + String expected_version = String.valueOf(SDL_MAJOR_VERSION) + "." + + String.valueOf(SDL_MINOR_VERSION) + "." + + String.valueOf(SDL_MICRO_VERSION); + String version = nativeGetVersion(); + if (!version.equals(expected_version)) { + mBrokenLibraries = true; + errorMsgBrokenLib = "SDL C/Java version mismatch (expected " + expected_version + ", got " + version + ")"; + } + } + + if (mBrokenLibraries) { + mSingleton = this; + AlertDialog.Builder dlgAlert = new AlertDialog.Builder(this); + dlgAlert.setMessage("An error occurred while trying to start the application. Please try again and/or reinstall." + + System.getProperty("line.separator") + + System.getProperty("line.separator") + + "Error: " + errorMsgBrokenLib); + dlgAlert.setTitle("SDL Error"); + dlgAlert.setPositiveButton("Exit", + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog,int id) { + // if this button is clicked, close current activity + SDLActivity.mSingleton.finish(); + } + }); + dlgAlert.setCancelable(false); + dlgAlert.create().show(); + + return; + } + + // Set up JNI + SDL.setupJNI(); + + // Initialize state + SDL.initialize(); + SDL.setContext(this); + + // So we can call stuff from static callbacks + mSingleton = this; + + mClipboardHandler = new SDLClipboardHandler(); + + mHIDDeviceManager = HIDDeviceManager.acquire(this); + + // Set up the surface + mSurface = createSDLSurface(getApplication()); + + mLayout = new RelativeLayout(this); + mLayout.addView(mSurface); + + // Get our current screen orientation and pass it down. + mCurrentOrientation = SDLActivity.getCurrentOrientation(); + // Only record current orientation + SDLActivity.onNativeOrientationChanged(mCurrentOrientation); + + try { + if (Build.VERSION.SDK_INT < 24) { + mCurrentLocale = getContext().getResources().getConfiguration().locale; + } else { + mCurrentLocale = getContext().getResources().getConfiguration().getLocales().get(0); + } + } catch(Exception ignored) { + } + + setContentView(mLayout); + + setWindowStyle(false); + + getWindow().getDecorView().setOnSystemUiVisibilityChangeListener(this); + + // Get filename from "Open with" of another application + Intent intent = getIntent(); + if (intent != null && intent.getData() != null) { + String filename = intent.getData().getPath(); + if (filename != null) { + Log.v(TAG, "Got filename: " + filename); + SDLActivity.onNativeDropFile(filename); + } + } + } + + protected void pauseNativeThread() { + mNextNativeState = NativeState.PAUSED; + mIsResumedCalled = false; + + if (SDLActivity.mBrokenLibraries) { + return; + } + + SDLActivity.handleNativeState(); + } + + protected void resumeNativeThread() { + mNextNativeState = NativeState.RESUMED; + mIsResumedCalled = true; + + if (SDLActivity.mBrokenLibraries) { + return; + } + + SDLActivity.handleNativeState(); + } + + // Events + @Override + protected void onPause() { + Log.v(TAG, "onPause()"); + super.onPause(); + + if (mHIDDeviceManager != null) { + mHIDDeviceManager.setFrozen(true); + } + if (!mHasMultiWindow) { + pauseNativeThread(); + } + } + + @Override + protected void onResume() { + Log.v(TAG, "onResume()"); + super.onResume(); + + if (mHIDDeviceManager != null) { + mHIDDeviceManager.setFrozen(false); + } + if (!mHasMultiWindow) { + resumeNativeThread(); + } + } + + @Override + protected void onStop() { + Log.v(TAG, "onStop()"); + super.onStop(); + if (mHasMultiWindow) { + pauseNativeThread(); + } + } + + @Override + protected void onStart() { + Log.v(TAG, "onStart()"); + super.onStart(); + if (mHasMultiWindow) { + resumeNativeThread(); + } + } + + public static int getCurrentOrientation() { + int result = SDL_ORIENTATION_UNKNOWN; + + Activity activity = (Activity)getContext(); + if (activity == null) { + return result; + } + Display display = activity.getWindowManager().getDefaultDisplay(); + + switch (display.getRotation()) { + case Surface.ROTATION_0: + result = SDL_ORIENTATION_PORTRAIT; + break; + + case Surface.ROTATION_90: + result = SDL_ORIENTATION_LANDSCAPE; + break; + + case Surface.ROTATION_180: + result = SDL_ORIENTATION_PORTRAIT_FLIPPED; + break; + + case Surface.ROTATION_270: + result = SDL_ORIENTATION_LANDSCAPE_FLIPPED; + break; + } + + return result; + } + + @Override + public void onWindowFocusChanged(boolean hasFocus) { + super.onWindowFocusChanged(hasFocus); + Log.v(TAG, "onWindowFocusChanged(): " + hasFocus); + + if (SDLActivity.mBrokenLibraries) { + return; + } + + mHasFocus = hasFocus; + if (hasFocus) { + mNextNativeState = NativeState.RESUMED; + SDLActivity.getMotionListener().reclaimRelativeMouseModeIfNeeded(); + + SDLActivity.handleNativeState(); + nativeFocusChanged(true); + + } else { + nativeFocusChanged(false); + if (!mHasMultiWindow) { + mNextNativeState = NativeState.PAUSED; + SDLActivity.handleNativeState(); + } + } + } + + @Override + public void onLowMemory() { + Log.v(TAG, "onLowMemory()"); + super.onLowMemory(); + + if (SDLActivity.mBrokenLibraries) { + return; + } + + SDLActivity.nativeLowMemory(); + } + + @Override + public void onConfigurationChanged(Configuration newConfig) { + Log.v(TAG, "onConfigurationChanged()"); + super.onConfigurationChanged(newConfig); + + if (SDLActivity.mBrokenLibraries) { + return; + } + + if (mCurrentLocale == null || !mCurrentLocale.equals(newConfig.locale)) { + mCurrentLocale = newConfig.locale; + SDLActivity.onNativeLocaleChanged(); + } + } + + @Override + protected void onDestroy() { + Log.v(TAG, "onDestroy()"); + + if (mHIDDeviceManager != null) { + HIDDeviceManager.release(mHIDDeviceManager); + mHIDDeviceManager = null; + } + + if (SDLActivity.mBrokenLibraries) { + super.onDestroy(); + return; + } + + if (SDLActivity.mSDLThread != null) { + + // Send Quit event to "SDLThread" thread + SDLActivity.nativeSendQuit(); + + // Wait for "SDLThread" thread to end + try { + SDLActivity.mSDLThread.join(); + } catch(Exception e) { + Log.v(TAG, "Problem stopping SDLThread: " + e); + } + } + + SDLActivity.nativeQuit(); + + super.onDestroy(); + } + + @Override + public void onBackPressed() { + // Check if we want to block the back button in case of mouse right click. + // + // If we do, the normal hardware back button will no longer work and people have to use home, + // but the mouse right click will work. + // + boolean trapBack = SDLActivity.nativeGetHintBoolean("SDL_ANDROID_TRAP_BACK_BUTTON", false); + if (trapBack) { + // Exit and let the mouse handler handle this button (if appropriate) + return; + } + + // Default system back button behavior. + if (!isFinishing()) { + super.onBackPressed(); + } + } + + // Called by JNI from SDL. + public static void manualBackButton() { + mSingleton.pressBackButton(); + } + + // Used to get us onto the activity's main thread + public void pressBackButton() { + runOnUiThread(new Runnable() { + @Override + public void run() { + if (!SDLActivity.this.isFinishing()) { + SDLActivity.this.superOnBackPressed(); + } + } + }); + } + + // Used to access the system back behavior. + public void superOnBackPressed() { + super.onBackPressed(); + } + + @Override + public boolean dispatchKeyEvent(KeyEvent event) { + + if (SDLActivity.mBrokenLibraries) { + return false; + } + + int keyCode = event.getKeyCode(); + // Ignore certain special keys so they're handled by Android + if (keyCode == KeyEvent.KEYCODE_VOLUME_DOWN || + keyCode == KeyEvent.KEYCODE_VOLUME_UP || + keyCode == KeyEvent.KEYCODE_CAMERA || + keyCode == KeyEvent.KEYCODE_ZOOM_IN || /* API 11 */ + keyCode == KeyEvent.KEYCODE_ZOOM_OUT /* API 11 */ + ) { + return false; + } + return super.dispatchKeyEvent(event); + } + + /* Transition to next state */ + public static void handleNativeState() { + + if (mNextNativeState == mCurrentNativeState) { + // Already in same state, discard. + return; + } + + // Try a transition to init state + if (mNextNativeState == NativeState.INIT) { + + mCurrentNativeState = mNextNativeState; + return; + } + + // Try a transition to paused state + if (mNextNativeState == NativeState.PAUSED) { + if (mSDLThread != null) { + nativePause(); + } + if (mSurface != null) { + mSurface.handlePause(); + } + mCurrentNativeState = mNextNativeState; + return; + } + + // Try a transition to resumed state + if (mNextNativeState == NativeState.RESUMED) { + if (mSurface.mIsSurfaceReady && mHasFocus && mIsResumedCalled) { + if (mSDLThread == null) { + // This is the entry point to the C app. + // Start up the C app thread and enable sensor input for the first time + // FIXME: Why aren't we enabling sensor input at start? + + mSDLThread = new Thread(new SDLMain(), "SDLThread"); + mSurface.enableSensor(Sensor.TYPE_ACCELEROMETER, true); + mSDLThread.start(); + + // No nativeResume(), don't signal Android_ResumeSem + } else { + nativeResume(); + } + mSurface.handleResume(); + + mCurrentNativeState = mNextNativeState; + } + } + } + + // Messages from the SDLMain thread + static final int COMMAND_CHANGE_TITLE = 1; + static final int COMMAND_CHANGE_WINDOW_STYLE = 2; + static final int COMMAND_TEXTEDIT_HIDE = 3; + static final int COMMAND_SET_KEEP_SCREEN_ON = 5; + + protected static final int COMMAND_USER = 0x8000; + + protected static boolean mFullscreenModeActive; + + /** + * This method is called by SDL if SDL did not handle a message itself. + * This happens if a received message contains an unsupported command. + * Method can be overwritten to handle Messages in a different class. + * @param command the command of the message. + * @param param the parameter of the message. May be null. + * @return if the message was handled in overridden method. + */ + protected boolean onUnhandledMessage(int command, Object param) { + return false; + } + + /** + * A Handler class for Messages from native SDL applications. + * It uses current Activities as target (e.g. for the title). + * static to prevent implicit references to enclosing object. + */ + protected static class SDLCommandHandler extends Handler { + @Override + public void handleMessage(Message msg) { + Context context = SDL.getContext(); + if (context == null) { + Log.e(TAG, "error handling message, getContext() returned null"); + return; + } + switch (msg.arg1) { + case COMMAND_CHANGE_TITLE: + if (context instanceof Activity) { + ((Activity) context).setTitle((String)msg.obj); + } else { + Log.e(TAG, "error handling message, getContext() returned no Activity"); + } + break; + case COMMAND_CHANGE_WINDOW_STYLE: + if (Build.VERSION.SDK_INT >= 19) { + if (context instanceof Activity) { + Window window = ((Activity) context).getWindow(); + if (window != null) { + if ((msg.obj instanceof Integer) && ((Integer) msg.obj != 0)) { + int flags = View.SYSTEM_UI_FLAG_FULLSCREEN | + View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | + View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY | + View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | + View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | + View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.INVISIBLE; + window.getDecorView().setSystemUiVisibility(flags); + window.addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); + window.clearFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN); + SDLActivity.mFullscreenModeActive = true; + } else { + int flags = View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_VISIBLE; + window.getDecorView().setSystemUiVisibility(flags); + window.addFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN); + window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); + SDLActivity.mFullscreenModeActive = false; + } + } + } else { + Log.e(TAG, "error handling message, getContext() returned no Activity"); + } + } + break; + case COMMAND_TEXTEDIT_HIDE: + if (mTextEdit != null) { + // Note: On some devices setting view to GONE creates a flicker in landscape. + // Setting the View's sizes to 0 is similar to GONE but without the flicker. + // The sizes will be set to useful values when the keyboard is shown again. + mTextEdit.setLayoutParams(new RelativeLayout.LayoutParams(0, 0)); + + InputMethodManager imm = (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE); + imm.hideSoftInputFromWindow(mTextEdit.getWindowToken(), 0); + + mScreenKeyboardShown = false; + + mSurface.requestFocus(); + } + break; + case COMMAND_SET_KEEP_SCREEN_ON: + { + if (context instanceof Activity) { + Window window = ((Activity) context).getWindow(); + if (window != null) { + if ((msg.obj instanceof Integer) && ((Integer) msg.obj != 0)) { + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + } + } + } + break; + } + default: + if ((context instanceof SDLActivity) && !((SDLActivity) context).onUnhandledMessage(msg.arg1, msg.obj)) { + Log.e(TAG, "error handling message, command is " + msg.arg1); + } + } + } + } + + // Handler for the messages + Handler commandHandler = new SDLCommandHandler(); + + // Send a message from the SDLMain thread + boolean sendCommand(int command, Object data) { + Message msg = commandHandler.obtainMessage(); + msg.arg1 = command; + msg.obj = data; + boolean result = commandHandler.sendMessage(msg); + + if (Build.VERSION.SDK_INT >= 19) { + if (command == COMMAND_CHANGE_WINDOW_STYLE) { + // Ensure we don't return until the resize has actually happened, + // or 500ms have passed. + + boolean bShouldWait = false; + + if (data instanceof Integer) { + // Let's figure out if we're already laid out fullscreen or not. + Display display = ((WindowManager) getSystemService(Context.WINDOW_SERVICE)).getDefaultDisplay(); + DisplayMetrics realMetrics = new DisplayMetrics(); + display.getRealMetrics(realMetrics); + + boolean bFullscreenLayout = ((realMetrics.widthPixels == mSurface.getWidth()) && + (realMetrics.heightPixels == mSurface.getHeight())); + + if ((Integer) data == 1) { + // If we aren't laid out fullscreen or actively in fullscreen mode already, we're going + // to change size and should wait for surfaceChanged() before we return, so the size + // is right back in native code. If we're already laid out fullscreen, though, we're + // not going to change size even if we change decor modes, so we shouldn't wait for + // surfaceChanged() -- which may not even happen -- and should return immediately. + bShouldWait = !bFullscreenLayout; + } else { + // If we're laid out fullscreen (even if the status bar and nav bar are present), + // or are actively in fullscreen, we're going to change size and should wait for + // surfaceChanged before we return, so the size is right back in native code. + bShouldWait = bFullscreenLayout; + } + } + + if (bShouldWait && (SDLActivity.getContext() != null)) { + // We'll wait for the surfaceChanged() method, which will notify us + // when called. That way, we know our current size is really the + // size we need, instead of grabbing a size that's still got + // the navigation and/or status bars before they're hidden. + // + // We'll wait for up to half a second, because some devices + // take a surprisingly long time for the surface resize, but + // then we'll just give up and return. + // + synchronized (SDLActivity.getContext()) { + try { + SDLActivity.getContext().wait(500); + } catch (InterruptedException ie) { + ie.printStackTrace(); + } + } + } + } + } + + return result; + } + + // C functions we call + public static native String nativeGetVersion(); + public static native int nativeSetupJNI(); + public static native int nativeRunMain(String library, String function, Object arguments); + public static native void nativeLowMemory(); + public static native void nativeSendQuit(); + public static native void nativeQuit(); + public static native void nativePause(); + public static native void nativeResume(); + public static native void nativeFocusChanged(boolean hasFocus); + public static native void onNativeDropFile(String filename); + public static native void nativeSetScreenResolution(int surfaceWidth, int surfaceHeight, int deviceWidth, int deviceHeight, float rate); + public static native void onNativeResize(); + public static native void onNativeKeyDown(int keycode); + public static native void onNativeKeyUp(int keycode); + public static native boolean onNativeSoftReturnKey(); + public static native void onNativeKeyboardFocusLost(); + public static native void onNativeMouse(int button, int action, float x, float y, boolean relative); + public static native void onNativeTouch(int touchDevId, int pointerFingerId, + int action, float x, + float y, float p); + public static native void onNativeAccel(float x, float y, float z); + public static native void onNativeClipboardChanged(); + public static native void onNativeSurfaceCreated(); + public static native void onNativeSurfaceChanged(); + public static native void onNativeSurfaceDestroyed(); + public static native String nativeGetHint(String name); + public static native boolean nativeGetHintBoolean(String name, boolean default_value); + public static native void nativeSetenv(String name, String value); + public static native void onNativeOrientationChanged(int orientation); + public static native void nativeAddTouch(int touchId, String name); + public static native void nativePermissionResult(int requestCode, boolean result); + public static native void onNativeLocaleChanged(); + + /** + * This method is called by SDL using JNI. + */ + public static boolean setActivityTitle(String title) { + // Called from SDLMain() thread and can't directly affect the view + return mSingleton.sendCommand(COMMAND_CHANGE_TITLE, title); + } + + /** + * This method is called by SDL using JNI. + */ + public static void setWindowStyle(boolean fullscreen) { + // Called from SDLMain() thread and can't directly affect the view + mSingleton.sendCommand(COMMAND_CHANGE_WINDOW_STYLE, fullscreen ? 1 : 0); + } + + /** + * This method is called by SDL using JNI. + * This is a static method for JNI convenience, it calls a non-static method + * so that is can be overridden + */ + public static void setOrientation(int w, int h, boolean resizable, String hint) + { + if (mSingleton != null) { + mSingleton.setOrientationBis(w, h, resizable, hint); + } + } + + /** + * This can be overridden + */ + public void setOrientationBis(int w, int h, boolean resizable, String hint) + { + int orientation_landscape = -1; + int orientation_portrait = -1; + + /* If set, hint "explicitly controls which UI orientations are allowed". */ + if (hint.contains("LandscapeRight") && hint.contains("LandscapeLeft")) { + orientation_landscape = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE; + } else if (hint.contains("LandscapeRight")) { + orientation_landscape = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE; + } else if (hint.contains("LandscapeLeft")) { + orientation_landscape = ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE; + } + + if (hint.contains("Portrait") && hint.contains("PortraitUpsideDown")) { + orientation_portrait = ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT; + } else if (hint.contains("Portrait")) { + orientation_portrait = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT; + } else if (hint.contains("PortraitUpsideDown")) { + orientation_portrait = ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT; + } + + boolean is_landscape_allowed = (orientation_landscape != -1); + boolean is_portrait_allowed = (orientation_portrait != -1); + int req; /* Requested orientation */ + + /* No valid hint, nothing is explicitly allowed */ + if (!is_portrait_allowed && !is_landscape_allowed) { + if (resizable) { + /* All orientations are allowed */ + req = ActivityInfo.SCREEN_ORIENTATION_FULL_SENSOR; + } else { + /* Fixed window and nothing specified. Get orientation from w/h of created window */ + req = (w > h ? ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE : ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT); + } + } else { + /* At least one orientation is allowed */ + if (resizable) { + if (is_portrait_allowed && is_landscape_allowed) { + /* hint allows both landscape and portrait, promote to full sensor */ + req = ActivityInfo.SCREEN_ORIENTATION_FULL_SENSOR; + } else { + /* Use the only one allowed "orientation" */ + req = (is_landscape_allowed ? orientation_landscape : orientation_portrait); + } + } else { + /* Fixed window and both orientations are allowed. Choose one. */ + if (is_portrait_allowed && is_landscape_allowed) { + req = (w > h ? orientation_landscape : orientation_portrait); + } else { + /* Use the only one allowed "orientation" */ + req = (is_landscape_allowed ? orientation_landscape : orientation_portrait); + } + } + } + + Log.v(TAG, "setOrientation() requestedOrientation=" + req + " width=" + w +" height="+ h +" resizable=" + resizable + " hint=" + hint); + mSingleton.setRequestedOrientation(req); + } + + /** + * This method is called by SDL using JNI. + */ + public static void minimizeWindow() { + + if (mSingleton == null) { + return; + } + + Intent startMain = new Intent(Intent.ACTION_MAIN); + startMain.addCategory(Intent.CATEGORY_HOME); + startMain.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + mSingleton.startActivity(startMain); + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean shouldMinimizeOnFocusLoss() { +/* + if (Build.VERSION.SDK_INT >= 24) { + if (mSingleton == null) { + return true; + } + + if (mSingleton.isInMultiWindowMode()) { + return false; + } + + if (mSingleton.isInPictureInPictureMode()) { + return false; + } + } + + return true; +*/ + return false; + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean isScreenKeyboardShown() + { + if (mTextEdit == null) { + return false; + } + + if (!mScreenKeyboardShown) { + return false; + } + + InputMethodManager imm = (InputMethodManager) SDL.getContext().getSystemService(Context.INPUT_METHOD_SERVICE); + return imm.isAcceptingText(); + + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean supportsRelativeMouse() + { + // DeX mode in Samsung Experience 9.0 and earlier doesn't support relative mice properly under + // Android 7 APIs, and simply returns no data under Android 8 APIs. + // + // This is fixed in Samsung Experience 9.5, which corresponds to Android 8.1.0, and + // thus SDK version 27. If we are in DeX mode and not API 27 or higher, as a result, + // we should stick to relative mode. + // + if ((Build.VERSION.SDK_INT < 27) && isDeXMode()) { + return false; + } + + return SDLActivity.getMotionListener().supportsRelativeMouse(); + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean setRelativeMouseEnabled(boolean enabled) + { + if (enabled && !supportsRelativeMouse()) { + return false; + } + + return SDLActivity.getMotionListener().setRelativeMouseEnabled(enabled); + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean sendMessage(int command, int param) { + if (mSingleton == null) { + return false; + } + return mSingleton.sendCommand(command, param); + } + + /** + * This method is called by SDL using JNI. + */ + public static Context getContext() { + return SDL.getContext(); + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean isAndroidTV() { + UiModeManager uiModeManager = (UiModeManager) getContext().getSystemService(UI_MODE_SERVICE); + if (uiModeManager.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION) { + return true; + } + if (Build.MANUFACTURER.equals("MINIX") && Build.MODEL.equals("NEO-U1")) { + return true; + } + if (Build.MANUFACTURER.equals("Amlogic") && Build.MODEL.equals("X96-W")) { + return true; + } + return Build.MANUFACTURER.equals("Amlogic") && Build.MODEL.startsWith("TV"); + } + + public static double getDiagonal() + { + DisplayMetrics metrics = new DisplayMetrics(); + Activity activity = (Activity)getContext(); + if (activity == null) { + return 0.0; + } + activity.getWindowManager().getDefaultDisplay().getMetrics(metrics); + + double dWidthInches = metrics.widthPixels / (double)metrics.xdpi; + double dHeightInches = metrics.heightPixels / (double)metrics.ydpi; + + return Math.sqrt((dWidthInches * dWidthInches) + (dHeightInches * dHeightInches)); + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean isTablet() { + // If our diagonal size is seven inches or greater, we consider ourselves a tablet. + return (getDiagonal() >= 7.0); + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean isChromebook() { + if (getContext() == null) { + return false; + } + return getContext().getPackageManager().hasSystemFeature("org.chromium.arc.device_management"); + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean isDeXMode() { + if (Build.VERSION.SDK_INT < 24) { + return false; + } + try { + final Configuration config = getContext().getResources().getConfiguration(); + final Class configClass = config.getClass(); + return configClass.getField("SEM_DESKTOP_MODE_ENABLED").getInt(configClass) + == configClass.getField("semDesktopModeEnabled").getInt(config); + } catch(Exception ignored) { + return false; + } + } + + /** + * This method is called by SDL using JNI. + */ + public static DisplayMetrics getDisplayDPI() { + return getContext().getResources().getDisplayMetrics(); + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean getManifestEnvironmentVariables() { + try { + if (getContext() == null) { + return false; + } + + ApplicationInfo applicationInfo = getContext().getPackageManager().getApplicationInfo(getContext().getPackageName(), PackageManager.GET_META_DATA); + Bundle bundle = applicationInfo.metaData; + if (bundle == null) { + return false; + } + String prefix = "SDL_ENV."; + final int trimLength = prefix.length(); + for (String key : bundle.keySet()) { + if (key.startsWith(prefix)) { + String name = key.substring(trimLength); + String value = bundle.get(key).toString(); + nativeSetenv(name, value); + } + } + /* environment variables set! */ + return true; + } catch (Exception e) { + Log.v(TAG, "exception " + e.toString()); + } + return false; + } + + // This method is called by SDLControllerManager's API 26 Generic Motion Handler. + public static View getContentView() { + return mLayout; + } + + static class ShowTextInputTask implements Runnable { + /* + * This is used to regulate the pan&scan method to have some offset from + * the bottom edge of the input region and the top edge of an input + * method (soft keyboard) + */ + static final int HEIGHT_PADDING = 15; + + public int x, y, w, h; + + public ShowTextInputTask(int x, int y, int w, int h) { + this.x = x; + this.y = y; + this.w = w; + this.h = h; + + /* Minimum size of 1 pixel, so it takes focus. */ + if (this.w <= 0) { + this.w = 1; + } + if (this.h + HEIGHT_PADDING <= 0) { + this.h = 1 - HEIGHT_PADDING; + } + } + + @Override + public void run() { + RelativeLayout.LayoutParams params = new RelativeLayout.LayoutParams(w, h + HEIGHT_PADDING); + params.leftMargin = x; + params.topMargin = y; + + if (mTextEdit == null) { + mTextEdit = new DummyEdit(SDL.getContext()); + + mLayout.addView(mTextEdit, params); + } else { + mTextEdit.setLayoutParams(params); + } + + mTextEdit.setVisibility(View.VISIBLE); + mTextEdit.requestFocus(); + + InputMethodManager imm = (InputMethodManager) SDL.getContext().getSystemService(Context.INPUT_METHOD_SERVICE); + imm.showSoftInput(mTextEdit, 0); + + mScreenKeyboardShown = true; + } + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean showTextInput(int x, int y, int w, int h) { + // Transfer the task to the main thread as a Runnable + return mSingleton.commandHandler.post(new ShowTextInputTask(x, y, w, h)); + } + + public static boolean isTextInputEvent(KeyEvent event) { + + // Key pressed with Ctrl should be sent as SDL_KEYDOWN/SDL_KEYUP and not SDL_TEXTINPUT + if (event.isCtrlPressed()) { + return false; + } + + return event.isPrintingKey() || event.getKeyCode() == KeyEvent.KEYCODE_SPACE; + } + + public static boolean handleKeyEvent(View v, int keyCode, KeyEvent event, InputConnection ic) { + int deviceId = event.getDeviceId(); + int source = event.getSource(); + + if (source == InputDevice.SOURCE_UNKNOWN) { + InputDevice device = InputDevice.getDevice(deviceId); + if (device != null) { + source = device.getSources(); + } + } + +// if (event.getAction() == KeyEvent.ACTION_DOWN) { +// Log.v("SDL", "key down: " + keyCode + ", deviceId = " + deviceId + ", source = " + source); +// } else if (event.getAction() == KeyEvent.ACTION_UP) { +// Log.v("SDL", "key up: " + keyCode + ", deviceId = " + deviceId + ", source = " + source); +// } + + // Dispatch the different events depending on where they come from + // Some SOURCE_JOYSTICK, SOURCE_DPAD or SOURCE_GAMEPAD are also SOURCE_KEYBOARD + // So, we try to process them as JOYSTICK/DPAD/GAMEPAD events first, if that fails we try them as KEYBOARD + // + // Furthermore, it's possible a game controller has SOURCE_KEYBOARD and + // SOURCE_JOYSTICK, while its key events arrive from the keyboard source + // So, retrieve the device itself and check all of its sources + if (SDLControllerManager.isDeviceSDLJoystick(deviceId)) { + // Note that we process events with specific key codes here + if (event.getAction() == KeyEvent.ACTION_DOWN) { + if (SDLControllerManager.onNativePadDown(deviceId, keyCode) == 0) { + return true; + } + } else if (event.getAction() == KeyEvent.ACTION_UP) { + if (SDLControllerManager.onNativePadUp(deviceId, keyCode) == 0) { + return true; + } + } + } + + if ((source & InputDevice.SOURCE_KEYBOARD) == InputDevice.SOURCE_KEYBOARD) { + if (event.getAction() == KeyEvent.ACTION_DOWN) { + if (isTextInputEvent(event)) { + if (ic != null) { + ic.commitText(String.valueOf((char) event.getUnicodeChar()), 1); + } else { + SDLInputConnection.nativeCommitText(String.valueOf((char) event.getUnicodeChar()), 1); + } + } + onNativeKeyDown(keyCode); + return true; + } else if (event.getAction() == KeyEvent.ACTION_UP) { + onNativeKeyUp(keyCode); + return true; + } + } + + if ((source & InputDevice.SOURCE_MOUSE) == InputDevice.SOURCE_MOUSE) { + // on some devices key events are sent for mouse BUTTON_BACK/FORWARD presses + // they are ignored here because sending them as mouse input to SDL is messy + if ((keyCode == KeyEvent.KEYCODE_BACK) || (keyCode == KeyEvent.KEYCODE_FORWARD)) { + switch (event.getAction()) { + case KeyEvent.ACTION_DOWN: + case KeyEvent.ACTION_UP: + // mark the event as handled or it will be handled by system + // handling KEYCODE_BACK by system will call onBackPressed() + return true; + } + } + } + + return false; + } + + /** + * This method is called by SDL using JNI. + */ + public static Surface getNativeSurface() { + if (SDLActivity.mSurface == null) { + return null; + } + return SDLActivity.mSurface.getNativeSurface(); + } + + // Input + + /** + * This method is called by SDL using JNI. + */ + public static void initTouch() { + int[] ids = InputDevice.getDeviceIds(); + + for (int id : ids) { + InputDevice device = InputDevice.getDevice(id); + /* Allow SOURCE_TOUCHSCREEN and also Virtual InputDevices because they can send TOUCHSCREEN events */ + if (device != null && ((device.getSources() & InputDevice.SOURCE_TOUCHSCREEN) == InputDevice.SOURCE_TOUCHSCREEN + || device.isVirtual())) { + + int touchDevId = device.getId(); + /* + * 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; + } + nativeAddTouch(touchDevId, device.getName()); + } + } + } + + // Messagebox + + /** Result of current messagebox. Also used for blocking the calling thread. */ + protected final int[] messageboxSelection = new int[1]; + + /** + * This method is called by SDL using JNI. + * Shows the messagebox from UI thread and block calling thread. + * buttonFlags, buttonIds and buttonTexts must have same length. + * @param buttonFlags array containing flags for every button. + * @param buttonIds array containing id for every button. + * @param buttonTexts array containing text for every button. + * @param colors null for default or array of length 5 containing colors. + * @return button id or -1. + */ + public int messageboxShowMessageBox( + final int flags, + final String title, + final String message, + final int[] buttonFlags, + final int[] buttonIds, + final String[] buttonTexts, + final int[] colors) { + + messageboxSelection[0] = -1; + + // sanity checks + + if ((buttonFlags.length != buttonIds.length) && (buttonIds.length != buttonTexts.length)) { + return -1; // implementation broken + } + + // collect arguments for Dialog + + final Bundle args = new Bundle(); + args.putInt("flags", flags); + args.putString("title", title); + args.putString("message", message); + args.putIntArray("buttonFlags", buttonFlags); + args.putIntArray("buttonIds", buttonIds); + args.putStringArray("buttonTexts", buttonTexts); + args.putIntArray("colors", colors); + + // trigger Dialog creation on UI thread + + runOnUiThread(new Runnable() { + @Override + public void run() { + messageboxCreateAndShow(args); + } + }); + + // block the calling thread + + synchronized (messageboxSelection) { + try { + messageboxSelection.wait(); + } catch (InterruptedException ex) { + ex.printStackTrace(); + return -1; + } + } + + // return selected value + + return messageboxSelection[0]; + } + + protected void messageboxCreateAndShow(Bundle args) { + + // TODO set values from "flags" to messagebox dialog + + // get colors + + int[] colors = args.getIntArray("colors"); + int backgroundColor; + int textColor; + int buttonBorderColor; + int buttonBackgroundColor; + int buttonSelectedColor; + if (colors != null) { + int i = -1; + backgroundColor = colors[++i]; + textColor = colors[++i]; + buttonBorderColor = colors[++i]; + buttonBackgroundColor = colors[++i]; + buttonSelectedColor = colors[++i]; + } else { + backgroundColor = Color.TRANSPARENT; + textColor = Color.TRANSPARENT; + buttonBorderColor = Color.TRANSPARENT; + buttonBackgroundColor = Color.TRANSPARENT; + buttonSelectedColor = Color.TRANSPARENT; + } + + // create dialog with title and a listener to wake up calling thread + + final AlertDialog dialog = new AlertDialog.Builder(this).create(); + dialog.setTitle(args.getString("title")); + dialog.setCancelable(false); + dialog.setOnDismissListener(new DialogInterface.OnDismissListener() { + @Override + public void onDismiss(DialogInterface unused) { + synchronized (messageboxSelection) { + messageboxSelection.notify(); + } + } + }); + + // create text + + TextView message = new TextView(this); + message.setGravity(Gravity.CENTER); + message.setText(args.getString("message")); + if (textColor != Color.TRANSPARENT) { + message.setTextColor(textColor); + } + + // create buttons + + int[] buttonFlags = args.getIntArray("buttonFlags"); + int[] buttonIds = args.getIntArray("buttonIds"); + String[] buttonTexts = args.getStringArray("buttonTexts"); + + final SparseArray