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